diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle index cf98ed330..d242b1d5c 100644 --- a/booklore-api/build.gradle +++ b/booklore-api/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-configuration-processor' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // --- Database & Migration --- implementation 'org.mariadb.jdbc:mariadb-java-client:3.5.3' diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java deleted file mode 100644 index d7dc285ab..000000000 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/ApplicationStartupRunner.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.adityachandel.booklore.config.security; - -import com.adityachandel.booklore.service.user.UserCreatorService; -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - -@Slf4j -@AllArgsConstructor -@Component -public class ApplicationStartupRunner implements CommandLineRunner { - - private final UserCreatorService userCreatorService; - - @Override - public void run(String... args) { - if (!userCreatorService.doesAdminUserExist()) { - userCreatorService.createAdminUser(); - } - } -} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/AuthenticationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/AuthenticationService.java index ba00b2882..c05c83b2c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/AuthenticationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/AuthenticationService.java @@ -8,7 +8,7 @@ import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.RefreshTokenEntity; import com.adityachandel.booklore.repository.RefreshTokenRepository; import com.adityachandel.booklore.repository.UserRepository; -import com.adityachandel.booklore.service.user.UserCreatorService; +import com.adityachandel.booklore.service.user.UserProvisioningService; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -27,7 +27,7 @@ public class AuthenticationService { private final AppProperties appProperties; private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; - private final UserCreatorService userCreatorService; + private final UserProvisioningService userProvisioningService; private final PasswordEncoder passwordEncoder; private final JwtUtils jwtUtils; @@ -54,7 +54,7 @@ public class AuthenticationService { Optional user = userRepository.findByUsername(username); if (user.isEmpty() && appProperties.getRemoteAuth().isCreateNewUsers()) { - user = Optional.of(userCreatorService.createRemoteUser(name, username, email, groups)); + user = Optional.of(userProvisioningService.provisionRemoteUser(name, username, email, groups)); } if (user.isEmpty()) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java new file mode 100644 index 000000000..5f569eaf0 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/DualJwtAuthenticationFilter.java @@ -0,0 +1,174 @@ +package com.adityachandel.booklore.config.security; + +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.settings.OidcAutoProvisionDetails; +import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.AppSettingService; +import com.adityachandel.booklore.service.user.UserProvisioningService; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.DefaultJWKSetCache; +import com.nimbusds.jose.jwk.source.JWKSetCache; +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@AllArgsConstructor +public class DualJwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtils jwtUtils; + private final UserRepository userRepository; + private final AppSettingService appSettingService; + private final UserProvisioningService userProvisioningService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + String token = extractToken(request); + if (token == null) { + chain.doFilter(request, response); + return; + } + try { + if (jwtUtils.validateToken(token)) { + authenticateLocalUser(token, request); + } else if (appSettingService.getAppSettings().isOidcEnabled()) { + authenticateOidcUser(token, request); + } else { + log.debug("OIDC is disabled. Skipping OIDC authentication."); + } + } catch (Exception ex) { + log.error("Authentication error: {}", ex.getMessage(), ex); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + chain.doFilter(request, response); + } + + private void authenticateLocalUser(String token, HttpServletRequest request) { + Long userId = jwtUtils.extractUserId(token); + BookLoreUserEntity entity = userRepository.findById(userId).orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + userId)); + BookLoreUser user = BookLoreUserTransformer.toDTO(entity); + List authorities = getAuthorities(entity.getPermissions()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, authorities); + authentication.setDetails(new UserAuthenticationDetails(request, user.getId())); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void authenticateOidcUser(String token, HttpServletRequest request) { + try { + OidcProviderDetails providerDetails = appSettingService.getAppSettings().getOidcProviderDetails(); + String jwksUrl = providerDetails.getJwksUrl(); + if (jwksUrl == null || jwksUrl.isEmpty()) { + log.error("JWKS URL is not configured."); + throw ApiError.UNAUTHORIZED.createException("JWKS URL is not configured."); + } + + List defaultOidcUserPermissions = appSettingService.getAppSettings().getOidcAutoProvisionDetails().getDefaultPermissions(); + OidcProviderDetails.ClaimMapping claimMapping = providerDetails.getClaimMapping(); + URL jwksURL = new URI(jwksUrl).toURL(); + + DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(2000, 2000); + Duration ttl = Duration.ofHours(6); + Duration refresh = Duration.ofHours(1); + JWKSetCache jwkSetCache = new DefaultJWKSetCache(ttl.toMillis(), refresh.toMillis(), TimeUnit.MILLISECONDS); + JWKSource jwkSource = new RemoteJWKSet<>(jwksURL, resourceRetriever, jwkSetCache); + + 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"); + throw ApiError.UNAUTHORIZED.createException("Token has expired or is invalid."); + } + + String username = claimsSet.getStringClaim(claimMapping.getUsername()); + String email = claimsSet.getStringClaim(claimMapping.getEmail()); + String name = claimsSet.getStringClaim(claimMapping.getName()); + + boolean autoProvisionOidcUsers = appSettingService.getAppSettings().getOidcAutoProvisionDetails().isEnableAutoProvisioning(); + OidcAutoProvisionDetails oidcAutoProvisionDetails = appSettingService.getAppSettings().getOidcAutoProvisionDetails(); + + Optional userOpt = userRepository.findByUsername(username); + if (userOpt.isEmpty()) { + if (!autoProvisionOidcUsers) { + log.warn("User '{}' not found and auto-provisioning is disabled", username); + throw ApiError.UNAUTHORIZED.createException("User not found and auto-provisioning is disabled."); + } + log.info("Provisioning new OIDC user '{}'", username); + } + + BookLoreUserEntity entity = userOpt.orElseGet(() -> userProvisioningService.provisionOidcUser(username, email, name, oidcAutoProvisionDetails)); + + BookLoreUser user = BookLoreUserTransformer.toDTO(entity); + List authorities = getAuthorities(entity.getPermissions()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(user, null, authorities); + authentication.setDetails(new UserAuthenticationDetails(request, user.getId())); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (Exception e) { + log.error("OIDC authentication failed", e); + throw ApiError.UNAUTHORIZED.createException("OIDC JWT validation failed"); + } + } + + private String extractToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + return (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null; + } + + private List getAuthorities(UserPermissionsEntity permissions) { + List authorities = new ArrayList<>(); + if (permissions != null) { + addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload()); + addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload()); + addAuthorityIfPermissionGranted(authorities, "ROLE_EDIT_METADATA", permissions.isPermissionEditMetadata()); + addAuthorityIfPermissionGranted(authorities, "ROLE_MANIPULATE_LIBRARY", permissions.isPermissionManipulateLibrary()); + addAuthorityIfPermissionGranted(authorities, "ROLE_ADMIN", permissions.isPermissionAdmin()); + } + return authorities; + } + + private void addAuthorityIfPermissionGranted(List authorities, String role, boolean permissionGranted) { + if (permissionGranted) { + authorities.add(new SimpleGrantedAuthority(role)); + } + } +} \ No newline at end of file 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 4060953c8..339427cdc 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,5 +1,6 @@ package com.adityachandel.booklore.config.security; +import lombok.AllArgsConstructor; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,11 +12,8 @@ import org.springframework.security.config.annotation.method.configuration.Enabl import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.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.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -24,17 +22,13 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; +@AllArgsConstructor @EnableMethodSecurity @Configuration public class SecurityConfig { - private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomOpdsUserDetailsService customOpdsUserDetailsService; - - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CustomOpdsUserDetailsService customOpdsUserDetailsService) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.customOpdsUserDetailsService = customOpdsUserDetailsService; - } + private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter; @Bean public PasswordEncoder passwordEncoder() { @@ -46,19 +40,22 @@ public class SecurityConfig { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/v1/auth/register", "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/remote").permitAll() - .requestMatchers("/ws/**").permitAll() - .requestMatchers("/api/v1/books/*/cover").permitAll() - .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() - .requestMatchers("/api/v1/opds/**").authenticated() + .requestMatchers( + "/api/v1/auth/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/ws/**", + "/api/v1/books/*/cover", + "/api/v1/settings", + "/api/v1/setup", + "/api/v1/setup/**" + ).permitAll() .anyRequest().authenticated() ) - .httpBasic(customizer -> { - customizer.realmName("Booklore OPDS"); - }) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + .httpBasic(customizer -> customizer.realmName("Booklore OPDS")) + .addFilterBefore(dualJwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } 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 81b40d9be..76e7afd4c 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,5 +1,16 @@ package com.adityachandel.booklore.config.security; +import com.adityachandel.booklore.service.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.extern.slf4j.Slf4j; import org.springframework.core.Ordered; @@ -15,16 +26,20 @@ 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; -@AllArgsConstructor @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE + 99) +@AllArgsConstructor public class WebSocketAuthInterceptor implements ChannelInterceptor { private final JwtUtils jwtUtils; + private final AppSettingService appSettingService; @Override public Message preSend(Message message, MessageChannel channel) { @@ -54,12 +69,43 @@ public class WebSocketAuthInterceptor implements ChannelInterceptor { } private Authentication authenticateToken(String token) { - if (!jwtUtils.validateToken(token)) { - return null; + if (jwtUtils.validateToken(token)) { + String username = jwtUtils.extractUsername(token); + List authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + return new UsernamePasswordAuthenticationToken(username, null, authorities); } - String username = jwtUtils.extractUsername(token); - List authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); - return new UsernamePasswordAuthenticationToken(username, null, authorities); + 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; } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AuthenticationController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AuthenticationController.java index f12e77def..bce13952e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AuthenticationController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AuthenticationController.java @@ -6,7 +6,7 @@ import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.model.dto.UserCreateRequest; import com.adityachandel.booklore.model.dto.request.RefreshTokenRequest; import com.adityachandel.booklore.model.dto.request.UserLoginRequest; -import com.adityachandel.booklore.service.user.UserCreatorService; +import com.adityachandel.booklore.service.user.UserProvisioningService; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,13 +24,13 @@ import java.util.Map; public class AuthenticationController { private final AppProperties appProperties; - private final UserCreatorService userCreatorService; + private final UserProvisioningService userProvisioningService; private final AuthenticationService authenticationService; @PostMapping("/register") @PreAuthorize("@securityUtil.isAdmin()") public ResponseEntity registerUser(@RequestBody @Valid UserCreateRequest userCreateRequest) { - userCreatorService.registerUser(userCreateRequest); + userProvisioningService.provisionInternalUser(userCreateRequest); return ResponseEntity.noContent().build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/SetupController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/SetupController.java new file mode 100644 index 000000000..a29052d2c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/SetupController.java @@ -0,0 +1,35 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.exception.ErrorResponse; +import com.adityachandel.booklore.model.dto.request.InitialUserRequest; +import com.adityachandel.booklore.model.dto.response.SuccessResponse; +import com.adityachandel.booklore.service.user.UserProvisioningService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/setup") +@RequiredArgsConstructor +public class SetupController { + + private final UserProvisioningService userProvisioningService; + + @GetMapping("/status") + public ResponseEntity getSetupStatus() { + boolean isCompleted = userProvisioningService.isInitialUserAlreadyProvisioned(); + String message = isCompleted + ? "Initial setup has already been completed." + : "Initial setup is pending. No users have been created yet."; + return ResponseEntity.ok(new SuccessResponse<>(200, message, isCompleted)); + } + + @PostMapping + public ResponseEntity setupFirstUser(@RequestBody InitialUserRequest request) { + if (userProvisioningService.isInitialUserAlreadyProvisioned()) { + return ResponseEntity.status(403).body(new ErrorResponse(403, "Setup is disabled after the first user is created.")); + } + userProvisioningService.provisionInitialUser(request); + return ResponseEntity.ok(new SuccessResponse<>(200, "Admin user created successfully.")); + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java new file mode 100644 index 000000000..17f9a7d7f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -0,0 +1,28 @@ +package com.adityachandel.booklore.mapper.custom; + +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; + +public class BookLoreUserTransformer { + + public static BookLoreUser toDTO(BookLoreUserEntity userEntity) { + BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions(); + permissions.setAdmin(userEntity.getPermissions().isPermissionAdmin()); + permissions.setCanUpload(userEntity.getPermissions().isPermissionUpload()); + permissions.setCanDownload(userEntity.getPermissions().isPermissionDownload()); + permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata()); + permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook()); + permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary()); + + BookLoreUser bookLoreUser = new BookLoreUser(); + bookLoreUser.setId(userEntity.getId()); + bookLoreUser.setUsername(userEntity.getUsername()); + bookLoreUser.setName(userEntity.getName()); + bookLoreUser.setEmail(userEntity.getEmail()); + bookLoreUser.setDefaultPassword(userEntity.isDefaultPassword()); + bookLoreUser.setPermissions(permissions); + bookLoreUser.setBookPreferences(userEntity.getBookPreferences()); + + return bookLoreUser; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index e95ff1752..8cc87fd7f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -1,6 +1,7 @@ package com.adityachandel.booklore.model.dto; import com.adityachandel.booklore.model.dto.settings.BookPreferences; +import com.adityachandel.booklore.model.enums.ProvisioningMethod; import lombok.Data; import java.util.List; @@ -12,6 +13,7 @@ public class BookLoreUser { private boolean isDefaultPassword; private String name; private String email; + private ProvisioningMethod provisioningMethod; private List assignedLibraries; private UserPermissions permissions; private BookPreferences bookPreferences; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/InitialUserRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/InitialUserRequest.java new file mode 100644 index 000000000..f4f84a5b2 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/InitialUserRequest.java @@ -0,0 +1,21 @@ +package com.adityachandel.booklore.model.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class InitialUserRequest { + @NotBlank + private String username; + + @Email + @NotBlank + private String email; + + @NotBlank + private String name; + + @NotBlank + private String password; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SuccessResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SuccessResponse.java new file mode 100644 index 000000000..c82886890 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/SuccessResponse.java @@ -0,0 +1,27 @@ +package com.adityachandel.booklore.model.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SuccessResponse { + + private final int status; + private final String message; + private final T data; + private final LocalDateTime timestamp; + + public SuccessResponse(int status, String message, T data) { + this.status = status; + this.message = message; + this.data = data; + this.timestamp = LocalDateTime.now(); + } + + public SuccessResponse(int status, String message) { + this(status, message, null); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index bad2d1cf6..c9a053c72 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -6,6 +6,8 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @AllArgsConstructor @@ -17,4 +19,8 @@ public class AppSettings { private boolean similarBookRecommendation; private boolean opdsServerEnabled; private String uploadPattern; + + private boolean oidcEnabled; + private OidcProviderDetails oidcProviderDetails; + private OidcAutoProvisionDetails oidcAutoProvisionDetails; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcAutoProvisionDetails.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcAutoProvisionDetails.java new file mode 100644 index 000000000..8d8708a5a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcAutoProvisionDetails.java @@ -0,0 +1,12 @@ +package com.adityachandel.booklore.model.dto.settings; + +import lombok.Data; + +import java.util.List; + +@Data +public class OidcAutoProvisionDetails { + private boolean enableAutoProvisioning; + private List defaultPermissions; + private List defaultLibraryIds; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcProviderDetails.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcProviderDetails.java new file mode 100644 index 000000000..142368d05 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/OidcProviderDetails.java @@ -0,0 +1,19 @@ +package com.adityachandel.booklore.model.dto.settings; + +import lombok.Data; + +@Data +public class OidcProviderDetails { + private String providerName; + private String clientId; + private String issuerUri; + private String jwksUrl; + private ClaimMapping claimMapping; + + @Data + public static class ClaimMapping { + private String username; + private String name; + private String email; + } +} \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java index 0aadf938d..adbda550c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java @@ -2,6 +2,7 @@ package com.adityachandel.booklore.model.entity; import com.adityachandel.booklore.convertor.BookPreferencesConverter; import com.adityachandel.booklore.model.dto.settings.BookPreferences; +import com.adityachandel.booklore.model.enums.ProvisioningMethod; import jakarta.persistence.*; import lombok.*; @@ -38,6 +39,10 @@ public class BookLoreUserEntity { @Column(unique = true) private String email; + @Column(name = "provisioning_method") + @Enumerated(EnumType.STRING) + private ProvisioningMethod provisioningMethod; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ProvisioningMethod.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ProvisioningMethod.java new file mode 100644 index 000000000..ef8a4346e --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/ProvisioningMethod.java @@ -0,0 +1,5 @@ +package com.adityachandel.booklore.model.enums; + +public enum ProvisioningMethod { + LOCAL, OIDC, REMOTE +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/AppSettingService.java index 882c87cdd..753408ee4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/AppSettingService.java @@ -2,15 +2,17 @@ package com.adityachandel.booklore.service; import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions; import com.adityachandel.booklore.model.dto.settings.AppSettings; +import com.adityachandel.booklore.model.dto.settings.OidcAutoProvisionDetails; +import com.adityachandel.booklore.model.dto.settings.OidcProviderDetails; import com.adityachandel.booklore.model.entity.AppSettingEntity; import com.adityachandel.booklore.repository.AppSettingsRepository; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.List; import java.util.Map; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -28,6 +30,9 @@ public class AppSettingService { public static final String SIMILAR_BOOK_RECOMMENDATION = "similar_book_recommendation"; public static final String UPLOAD_FILE_PATTERN = "upload_file_pattern"; public static final String OPDS_SERVER_ENABLED = "opds_server_enabled"; + public static final String OIDC_ENABLED = "oidc_enabled"; + public static final String OIDC_PROVIDER_DETAILS = "oidc_provider_details"; + public static final String OIDC_AUTO_PROVISION_DETAILS = "oidc_auto_provision_details"; private volatile AppSettings appSettings; private final ReentrantLock lock = new ReentrantLock(); @@ -50,10 +55,11 @@ public class AppSettingService { public void updateSetting(String name, Object val) throws JsonProcessingException { AppSettingEntity setting = appSettingsRepository.findByName(name); if (setting == null) { - throw new IllegalArgumentException("Setting not found for name: " + name); + setting = new AppSettingEntity(); + setting.setName(name); } - if (QUICK_BOOK_MATCH.equals(name)) { + if (QUICK_BOOK_MATCH.equals(name) || OIDC_PROVIDER_DETAILS.equals(name) || OIDC_AUTO_PROVISION_DETAILS.equals(name)) { setting.setVal(objectMapper.writeValueAsString(val)); } else { setting.setVal(val.toString()); @@ -66,43 +72,70 @@ public class AppSettingService { private void refreshCache() { lock.lock(); try { - this.appSettings = buildAppSettings(); + appSettings = buildAppSettings(); } finally { lock.unlock(); } } private AppSettings buildAppSettings() { - List settings = appSettingsRepository.findAll(); - Map settingsMap = settings.stream().collect(Collectors.toMap(AppSettingEntity::getName, AppSettingEntity::getVal)); + Map settingsMap = appSettingsRepository.findAll().stream().collect(Collectors.toMap(AppSettingEntity::getName, AppSettingEntity::getVal)); + AppSettings.AppSettingsBuilder builder = AppSettings.builder(); if (settingsMap.containsKey(QUICK_BOOK_MATCH)) { try { - MetadataRefreshOptions options = objectMapper.readValue(settingsMap.get(QUICK_BOOK_MATCH), MetadataRefreshOptions.class); - builder.metadataRefreshOptions(options); + builder.metadataRefreshOptions(objectMapper.readValue(settingsMap.get(QUICK_BOOK_MATCH), MetadataRefreshOptions.class)); } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to parse setting: " + QUICK_BOOK_MATCH, e); + throw new RuntimeException("Failed to parse " + QUICK_BOOK_MATCH, e); } } + String oidcProviderDetailsJson = settingsMap.get(OIDC_PROVIDER_DETAILS); + if (oidcProviderDetailsJson != null && !oidcProviderDetailsJson.isBlank()) { + try { + builder.oidcProviderDetails(objectMapper.readValue(oidcProviderDetailsJson, OidcProviderDetails.class)); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse " + OIDC_PROVIDER_DETAILS, e); + } + } else { + builder.oidcProviderDetails(null); + } + + String oidcAutoProvisionDetailsJson = settingsMap.get(OIDC_AUTO_PROVISION_DETAILS); + if (oidcAutoProvisionDetailsJson != null && !oidcAutoProvisionDetailsJson.isBlank()) { + try { + builder.oidcAutoProvisionDetails(objectMapper.readValue(oidcAutoProvisionDetailsJson, OidcAutoProvisionDetails.class)); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse " + OIDC_AUTO_PROVISION_DETAILS, e); + } + } else { + builder.oidcAutoProvisionDetails(null); + } + builder.coverResolution(getOrCreateSetting(COVER_IMAGE_RESOLUTION, "250x350")); builder.autoBookSearch(Boolean.parseBoolean(getOrCreateSetting(AUTO_BOOK_SEARCH, "true"))); builder.uploadPattern(getOrCreateSetting(UPLOAD_FILE_PATTERN, "")); builder.similarBookRecommendation(Boolean.parseBoolean(getOrCreateSetting(SIMILAR_BOOK_RECOMMENDATION, "true"))); builder.opdsServerEnabled(Boolean.parseBoolean(getOrCreateSetting(OPDS_SERVER_ENABLED, "false"))); + builder.oidcEnabled(Boolean.parseBoolean(getOrCreateSetting(OIDC_ENABLED, "false"))); + return builder.build(); } private String getOrCreateSetting(String name, String defaultValue) { - AppSettingEntity existing = appSettingsRepository.findByName(name); - if (existing != null) { - return existing.getVal(); + AppSettingEntity setting = appSettingsRepository.findByName(name); + if (setting != null) { + return setting.getVal(); } - AppSettingEntity newSetting = new AppSettingEntity(); - newSetting.setName(name); - newSetting.setVal(defaultValue); - appSettingsRepository.save(newSetting); + saveDefaultSetting(name, defaultValue); return defaultValue; } + + private void saveDefaultSetting(String name, String value) { + AppSettingEntity setting = new AppSettingEntity(); + setting.setName(name); + setting.setVal(value); + appSettingsRepository.save(setting); + } } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java similarity index 58% rename from booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java rename to booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java index ca10c84c1..65263435f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserCreatorService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java @@ -2,40 +2,68 @@ package com.adityachandel.booklore.service.user; import com.adityachandel.booklore.config.AppProperties; import com.adityachandel.booklore.exception.ApiError; -import com.adityachandel.booklore.model.dto.settings.BookPreferences; import com.adityachandel.booklore.model.dto.UserCreateRequest; +import com.adityachandel.booklore.model.dto.request.InitialUserRequest; +import com.adityachandel.booklore.model.dto.settings.BookPreferences; +import com.adityachandel.booklore.model.dto.settings.OidcAutoProvisionDetails; import com.adityachandel.booklore.model.entity.BookLoreUserEntity; import com.adityachandel.booklore.model.entity.LibraryEntity; import com.adityachandel.booklore.model.entity.ShelfEntity; import com.adityachandel.booklore.model.entity.UserPermissionsEntity; +import com.adityachandel.booklore.model.enums.ProvisioningMethod; import com.adityachandel.booklore.repository.LibraryRepository; import com.adityachandel.booklore.repository.ShelfRepository; import com.adityachandel.booklore.repository.UserRepository; + import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; -import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; +import java.util.*; @Slf4j @Service @AllArgsConstructor -public class UserCreatorService { +public class UserProvisioningService { private final AppProperties appProperties; - private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; private final LibraryRepository libraryRepository; private final ShelfRepository shelfRepository; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + public boolean isInitialUserAlreadyProvisioned() { + return userRepository.count() > 0; + } @Transactional - public void registerUser(UserCreateRequest request) { + public void provisionInitialUser(InitialUserRequest request) { + BookLoreUserEntity user = new BookLoreUserEntity(); + user.setUsername(request.getUsername()); + user.setEmail(request.getEmail()); + user.setName(request.getName()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setDefaultPassword(false); + user.setProvisioningMethod(ProvisioningMethod.LOCAL); + user.setBookPreferences(buildDefaultBookPreferences()); + + UserPermissionsEntity perms = new UserPermissionsEntity(); + perms.setPermissionAdmin(true); + perms.setPermissionUpload(true); + perms.setPermissionDownload(true); + perms.setPermissionEditMetadata(true); + perms.setPermissionManipulateLibrary(true); + perms.setPermissionEmailBook(true); + + user.setPermissions(perms); + createUser(user); + } + + @Transactional + public void provisionInternalUser(UserCreateRequest request) { Optional existingUser = userRepository.findByUsername(request.getUsername()); if (existingUser.isPresent()) { throw ApiError.USERNAME_ALREADY_TAKEN.createException(request.getUsername()); @@ -47,6 +75,7 @@ public class UserCreatorService { user.setPasswordHash(passwordEncoder.encode(request.getPassword())); user.setName(request.getName()); user.setEmail(request.getEmail()); + user.setProvisioningMethod(ProvisioningMethod.LOCAL); UserPermissionsEntity permissions = new UserPermissionsEntity(); permissions.setUser(user); @@ -67,7 +96,38 @@ public class UserCreatorService { } @Transactional - public BookLoreUserEntity createRemoteUser(String name, String username, String email, String groups) { + public BookLoreUserEntity provisionOidcUser(String username, String email, String name, OidcAutoProvisionDetails oidcAutoProvisionDetails) { + BookLoreUserEntity user = new BookLoreUserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setName(name); + user.setDefaultPassword(false); + user.setPasswordHash("OIDC_USER_" + UUID.randomUUID()); + user.setProvisioningMethod(ProvisioningMethod.OIDC); + user.setBookPreferences(buildDefaultBookPreferences()); + + UserPermissionsEntity perms = new UserPermissionsEntity(); + List defaultPermissions = oidcAutoProvisionDetails.getDefaultPermissions(); + if (defaultPermissions != null) { + perms.setPermissionUpload(defaultPermissions.contains("permissionUpload")); + perms.setPermissionDownload(defaultPermissions.contains("permissionDownload")); + perms.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata")); + perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary")); + perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); + } + user.setPermissions(perms); + + List defaultLibraryIds = oidcAutoProvisionDetails.getDefaultLibraryIds(); + if(defaultLibraryIds != null && !defaultLibraryIds.isEmpty()) { + List libraries = libraryRepository.findAllById(defaultLibraryIds); + user.setLibraries(new ArrayList<>(libraries)); + } + return createUser(user); + } + + @Deprecated + @Transactional + public BookLoreUserEntity provisionRemoteUser(String name, String username, String email, String groups) { boolean isAdmin = false; if (groups != null && appProperties.getRemoteAuth().getAdminGroup() != null) { String groupsContent = groups.trim(); @@ -84,7 +144,8 @@ public class UserCreatorService { user.setName(name != null ? name : username); user.setEmail(email); user.setDefaultPassword(false); - user.setPasswordHash(passwordEncoder.encode(RandomStringUtils.secure().nextAlphanumeric(32))); + user.setProvisioningMethod(ProvisioningMethod.REMOTE); + user.setPasswordHash("RemoteUser_" + RandomStringUtils.secure().nextAlphanumeric(32)); UserPermissionsEntity permissions = new UserPermissionsEntity(); permissions.setUser(user); @@ -105,44 +166,19 @@ public class UserCreatorService { } @Transactional - public void createAdminUser() { - BookLoreUserEntity user = new BookLoreUserEntity(); - user.setUsername("admin"); - user.setPasswordHash(passwordEncoder.encode("admin123")); - user.setDefaultPassword(true); - user.setName("Administrator"); - user.setEmail("admin@email.com"); - - UserPermissionsEntity permissions = new UserPermissionsEntity(); - permissions.setUser(user); - permissions.setPermissionUpload(true); - permissions.setPermissionDownload(true); - permissions.setPermissionManipulateLibrary(true); - permissions.setPermissionEditMetadata(true); - permissions.setPermissionEmailBook(true); - permissions.setPermissionAdmin(true); - - user.setPermissions(permissions); - user.setBookPreferences(buildDefaultBookPreferences()); - - createUser(user); - log.info("Created admin user {}", user.getUsername()); - } - - @Transactional - BookLoreUserEntity createUser(BookLoreUserEntity user) { - ShelfEntity shelfEntity = ShelfEntity.builder() - .user(user) - .name("Favorites") - .icon("heart") - .build(); + protected BookLoreUserEntity createUser(BookLoreUserEntity user) { user = userRepository.save(user); - shelfRepository.save(shelfEntity); - return user; - } - public boolean doesAdminUserExist() { - return userRepository.findByUsername("admin").isPresent(); + if (user.getShelves() == null || user.getShelves().isEmpty()) { + ShelfEntity shelfEntity = ShelfEntity.builder() + .user(user) + .name("Favorites") + .icon("heart") + .build(); + shelfRepository.save(shelfEntity); + } + + return user; } private BookPreferences buildDefaultBookPreferences() { @@ -162,5 +198,4 @@ public class UserCreatorService { .build()) .build(); } - } \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V12__Update_app_settings_table.sql b/booklore-api/src/main/resources/db/migration/V12__Update_app_settings_table.sql new file mode 100644 index 000000000..367dc89c2 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V12__Update_app_settings_table.sql @@ -0,0 +1,6 @@ +ALTER TABLE app_settings + MODIFY COLUMN name VARCHAR(255) NOT NULL, + ADD UNIQUE INDEX uq_app_settings_name (name); + +ALTER TABLE app_settings + MODIFY COLUMN val TEXT NULL; \ No newline at end of file diff --git a/booklore-api/src/main/resources/db/migration/V13__Add_provisioned_method_column_to_users.sql b/booklore-api/src/main/resources/db/migration/V13__Add_provisioned_method_column_to_users.sql new file mode 100644 index 000000000..fdd402398 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V13__Add_provisioned_method_column_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN provisioning_method VARCHAR(50); \ No newline at end of file diff --git a/booklore-ui/angular.json b/booklore-ui/angular.json index 44f67bc3a..2a1d08f05 100644 --- a/booklore-ui/angular.json +++ b/booklore-ui/angular.json @@ -62,7 +62,7 @@ { "type": "initial", "maximumWarning": "500kB", - "maximumError": "3MB" + "maximumError": "3.5MB" }, { "type": "anyComponentStyle", diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index 7b8fbc704..2667423a6 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -21,6 +21,7 @@ "@stomp/rx-stomp": "2.0.1", "@stomp/stompjs": "7.1.1", "@tailwindcss/postcss": "^4.1.4", + "angular-oauth2-oidc": "^19.0.0", "epubjs": "0.3.93", "jwt-decode": "4.0.0", "ng-lazyload-image": "9.1.3", @@ -6873,6 +6874,19 @@ "typescript-eslint": "^8.0.0" } }, + "node_modules/angular-oauth2-oidc": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-19.0.0.tgz", + "integrity": "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.2" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/booklore-ui/package.json b/booklore-ui/package.json index 166e12fb8..7692da6e7 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -25,6 +25,7 @@ "@stomp/rx-stomp": "2.0.1", "@stomp/stompjs": "7.1.1", "@tailwindcss/postcss": "^4.1.4", + "angular-oauth2-oidc": "^19.0.0", "epubjs": "0.3.93", "jwt-decode": "4.0.0", "ng-lazyload-image": "9.1.3", diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 10a926968..ebd7a4bea 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -9,13 +9,26 @@ import {PdfViewerComponent} from './book/components/pdf-viewer/pdf-viewer.compon import {EpubViewerComponent} from './book/components/epub-viewer/component/epub-viewer.component'; import {ChangePasswordComponent} from './core/component/change-password/change-password.component'; import {BookMetadataCenterComponent} from './metadata/book-metadata-center/book-metadata-center.component'; +import {SetupComponent} from './setup/setup.component'; +import {SetupGuard} from './setup/setup.guard'; +import {SetupRedirectGuard} from './setup/setup-redirect.guard'; +import {EmptyComponent} from './empty/empty.component'; +import {LoginGuard} from './setup/ login.guard'; +import {OidcCallbackComponent} from './oidc-callback/oidc-callback.component'; export const routes: Routes = [ { path: '', - redirectTo: 'dashboard', - pathMatch: 'full' + canActivate: [SetupRedirectGuard], + pathMatch: 'full', + component: EmptyComponent }, + { + path: 'setup', + component: SetupComponent, + canActivate: [SetupGuard] + }, + {path: 'oauth2-callback', component: OidcCallbackComponent}, { path: '', component: AppLayoutComponent, @@ -38,7 +51,19 @@ export const routes: Routes = [ component: EpubViewerComponent, canActivate: [AuthGuard] }, - {path: 'login', component: LoginComponent}, - {path: 'change-password', component: ChangePasswordComponent}, - {path: '**', redirectTo: 'login', pathMatch: 'full'} + { + path: 'login', + component: LoginComponent, + canActivate: [LoginGuard] + }, + { + path: 'change-password', + component: ChangePasswordComponent, + canActivate: [SetupGuard] + }, + { + path: '**', + redirectTo: 'login', + pathMatch: 'full' + } ]; diff --git a/booklore-ui/src/app/auth-initializer.ts b/booklore-ui/src/app/auth-initializer.ts new file mode 100644 index 000000000..72a02ef16 --- /dev/null +++ b/booklore-ui/src/app/auth-initializer.ts @@ -0,0 +1,52 @@ +import {inject} from '@angular/core'; +import {OAuthEvent, OAuthService} from 'angular-oauth2-oidc'; +import {AppSettingsService} from './core/service/app-settings.service'; +import {AuthService, websocketInitializer} from './core/service/auth.service'; +import {filter} from 'rxjs/operators'; + +export function initializeAuthFactory() { + return () => { + const oauthService = inject(OAuthService); + const appSettingsService = inject(AppSettingsService); + const authService = inject(AuthService); + + return new Promise((resolve) => { + const sub = appSettingsService.appSettings$.subscribe(settings => { + if (settings) { + if (settings.oidcEnabled && settings.oidcProviderDetails) { + const details = settings.oidcProviderDetails; + oauthService.configure({ + issuer: details.issuerUri, + clientId: details.clientId, + scope: 'openid profile email offline_access', + redirectUri: window.location.origin + '/oauth2-callback', + responseType: 'code', + showDebugInformation: true, + requireHttps: false, + strictDiscoveryDocumentValidation: false, + }); + + oauthService.events + .pipe(filter((e: OAuthEvent) => + e.type === 'token_received' || e.type === 'token_refreshed' + )) + .subscribe((e: OAuthEvent) => { + const accessToken = oauthService.getAccessToken(); + const refreshToken = oauthService.getRefreshToken(); + authService.saveOidcTokens(accessToken, refreshToken ?? ''); + }); + + oauthService.loadDiscoveryDocumentAndTryLogin().then(() => { + oauthService.setupAutomaticSilentRefresh(); + websocketInitializer(authService); + resolve(); + }); + } else { + resolve(); + } + sub.unsubscribe(); + } + }); + }); + }; +} diff --git a/booklore-ui/src/app/auth-interceptor.service.ts b/booklore-ui/src/app/auth-interceptor.service.ts index 816a52144..abb5162f0 100644 --- a/booklore-ui/src/app/auth-interceptor.service.ts +++ b/booklore-ui/src/app/auth-interceptor.service.ts @@ -4,23 +4,24 @@ import {Router} from '@angular/router'; import {catchError, filter, switchMap, take} from 'rxjs/operators'; import {BehaviorSubject, Observable, throwError} from 'rxjs'; import {AuthService} from './core/service/auth.service'; +import {API_CONFIG} from './config/api-config'; export const AuthInterceptorService: HttpInterceptorFn = (req, next: HttpHandlerFn) => { const authService = inject(AuthService); const router = inject(Router); - const token = authService.getToken(); - let authReq = req; - if (token) { - authReq = req.clone({ - setHeaders: {Authorization: `Bearer ${token}`} - }); - } + const internalToken = authService.getInternalAccessToken(); + const oidcToken = authService.getOidcAccessToken(); + const token = internalToken || oidcToken; + + const isApiRequest = req.url.startsWith(`${API_CONFIG.BASE_URL}/api/`); + + const authReq = (token && isApiRequest) ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req; return next(authReq).pipe( catchError((error: HttpErrorResponse) => { if (error.status === 401 || error.status === 403) { - return handle401Error(authService, authReq, next, router); + return handle401Error(authService, authReq, next, router, !!internalToken); } return throwError(() => error); }) @@ -30,32 +31,49 @@ export const AuthInterceptorService: HttpInterceptorFn = (req, next: HttpHandler let isRefreshing = false; const refreshTokenSubject = new BehaviorSubject(null); -function handle401Error(authService: AuthService, request: HttpRequest, next: HttpHandlerFn, router: Router): Observable { - if (!isRefreshing) { +function handle401Error(authService: AuthService, request: HttpRequest, next: HttpHandlerFn, router: Router, isInternal: boolean): Observable { + if (!isRefreshing && isInternal) { isRefreshing = true; refreshTokenSubject.next(null); - return authService.refreshToken().pipe( - switchMap((response) => { + return authService.internalRefreshToken().pipe( + switchMap(response => { isRefreshing = false; - refreshTokenSubject.next(response.accessToken); - if (response.accessToken && response.refreshToken) { - authService.saveTokens(response.accessToken, response.refreshToken); + const { accessToken, refreshToken } = response; + if (accessToken && refreshToken) { + authService.saveInternalTokens(accessToken, refreshToken); + refreshTokenSubject.next(accessToken); } - return next(request.clone({setHeaders: {Authorization: `Bearer ${response.accessToken}`}})); + return next(request.clone({ + setHeaders: { Authorization: `Bearer ${accessToken}` } + })); }), - catchError((err) => { + catchError(err => { isRefreshing = false; - authService.logout(); - router.navigate(['/login']); + forceLogout(authService, router); return throwError(() => err); }) ); - } else { + } + + if (isRefreshing && isInternal) { return refreshTokenSubject.pipe( filter(token => token !== null), take(1), - switchMap((token) => next(request.clone({setHeaders: {Authorization: `Bearer ${token}`}}))) + switchMap(token => + next(request.clone({ + setHeaders: { Authorization: `Bearer ${token}` } + })) + ) ); } + + forceLogout(authService, router, isInternal ? 'Session expired, please log in again.' : 'OIDC token expired, please log in again.'); + return throwError(() => new Error('Authentication failed, please log in.')); +} + +function forceLogout(authService: AuthService, router: Router, message?: string): void { + authService.logout(); + router.navigate(['/login']); + if (message) console.warn(message); } diff --git a/booklore-ui/src/app/auth.guard.ts b/booklore-ui/src/app/auth.guard.ts index 366e2787b..0622f6d2c 100644 --- a/booklore-ui/src/app/auth.guard.ts +++ b/booklore-ui/src/app/auth.guard.ts @@ -1,21 +1,35 @@ -import {inject} from '@angular/core'; -import {CanActivateFn, Router} from '@angular/router'; +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { Router } from '@angular/router'; +import { OAuthService } from 'angular-oauth2-oidc'; export const AuthGuard: CanActivateFn = (route, state) => { const router = inject(Router); - const token = localStorage.getItem('accessToken'); - if (!token) { - router.navigate(['/login']); - return false; + const legacyToken = localStorage.getItem('accessToken_Internal'); + + if (legacyToken) { + try { + const payload = JSON.parse(atob(legacyToken.split('.')[1])); + if (payload.isDefaultPassword) { + router.navigate(['/change-password']); + return false; + } + return true; + } catch (e) { + console.error('Invalid legacy token:', e); + localStorage.removeItem('accessToken'); + router.navigate(['/login']); + return false; + } } - const payload = JSON.parse(atob(token.split('.')[1])); + const oidcToken = localStorage.getItem('accessToken_OIDC'); - if (payload.isDefaultPassword) { - router.navigate(['/change-password']); - return false; + if (oidcToken) { + return true; } - return true; + router.navigate(['/login']); + return false; }; diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index 4c4418696..2998e7a54 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -101,7 +101,7 @@

- {{ entityType === EntityType.LIBRARY ? "This library has no books!" : "This shelf has no books!" }} + This collection has no books!

diff --git a/booklore-ui/src/app/core/component/login/login.component.html b/booklore-ui/src/app/core/component/login/login.component.html index 049a650df..6db5f77c5 100644 --- a/booklore-ui/src/app/core/component/login/login.component.html +++ b/booklore-ui/src/app/core/component/login/login.component.html @@ -1,26 +1,35 @@ -