Various Improvements.
- Testing Coverage - Image Handling - Session Handling - Library Switching
This commit is contained in:
@@ -40,6 +40,8 @@ public class SecurityConfig {
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/auth/login").permitAll()
|
||||
.requestMatchers("/api/auth/refresh").permitAll() // Allow refresh without access token
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/files/images/**").permitAll() // Public image serving
|
||||
.requestMatchers("/api/config/**").permitAll() // Public configuration endpoints
|
||||
|
||||
@@ -45,6 +45,7 @@ public class SolrProperties {
|
||||
public static class Cores {
|
||||
private String stories = "storycove_stories";
|
||||
private String authors = "storycove_authors";
|
||||
private String collections = "storycove_collections";
|
||||
|
||||
// Getters and setters
|
||||
public String getStories() { return stories; }
|
||||
@@ -52,6 +53,9 @@ public class SolrProperties {
|
||||
|
||||
public String getAuthors() { return authors; }
|
||||
public void setAuthors(String authors) { this.authors = authors; }
|
||||
|
||||
public String getCollections() { return collections; }
|
||||
public void setCollections(String collections) { this.collections = collections; }
|
||||
}
|
||||
|
||||
public static class Connection {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.storycove.config;
|
||||
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.repository.AuthorRepository;
|
||||
import com.storycove.repository.CollectionRepository;
|
||||
import com.storycove.repository.StoryRepository;
|
||||
import com.storycove.service.SearchServiceAdapter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Automatically performs bulk reindexing of all entities on application startup.
|
||||
* This ensures that the search index is always in sync with the database,
|
||||
* especially after Solr volume recreation during deployment.
|
||||
*/
|
||||
@Component
|
||||
public class StartupIndexingRunner implements ApplicationRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StartupIndexingRunner.class);
|
||||
|
||||
@Autowired
|
||||
private SearchServiceAdapter searchServiceAdapter;
|
||||
|
||||
@Autowired
|
||||
private StoryRepository storyRepository;
|
||||
|
||||
@Autowired
|
||||
private AuthorRepository authorRepository;
|
||||
|
||||
@Autowired
|
||||
private CollectionRepository collectionRepository;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
logger.info("========================================");
|
||||
logger.info("Starting automatic bulk reindexing...");
|
||||
logger.info("========================================");
|
||||
|
||||
try {
|
||||
// Check if search service is available
|
||||
if (!searchServiceAdapter.isSearchServiceAvailable()) {
|
||||
logger.warn("Search service (Solr) is not available. Skipping bulk reindexing.");
|
||||
logger.warn("Make sure Solr is running and accessible.");
|
||||
return;
|
||||
}
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// Index all stories
|
||||
logger.info("📚 Indexing stories...");
|
||||
List<Story> stories = storyRepository.findAllWithAssociations();
|
||||
if (!stories.isEmpty()) {
|
||||
searchServiceAdapter.bulkIndexStories(stories);
|
||||
logger.info("✅ Indexed {} stories", stories.size());
|
||||
} else {
|
||||
logger.info("ℹ️ No stories to index");
|
||||
}
|
||||
|
||||
// Index all authors
|
||||
logger.info("👤 Indexing authors...");
|
||||
List<Author> authors = authorRepository.findAll();
|
||||
if (!authors.isEmpty()) {
|
||||
searchServiceAdapter.bulkIndexAuthors(authors);
|
||||
logger.info("✅ Indexed {} authors", authors.size());
|
||||
} else {
|
||||
logger.info("ℹ️ No authors to index");
|
||||
}
|
||||
|
||||
// Index all collections
|
||||
logger.info("📂 Indexing collections...");
|
||||
List<Collection> collections = collectionRepository.findAllWithTags();
|
||||
if (!collections.isEmpty()) {
|
||||
searchServiceAdapter.bulkIndexCollections(collections);
|
||||
logger.info("✅ Indexed {} collections", collections.size());
|
||||
} else {
|
||||
logger.info("ℹ️ No collections to index");
|
||||
}
|
||||
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
logger.info("========================================");
|
||||
logger.info("✅ Bulk reindexing completed successfully in {}ms", duration);
|
||||
logger.info("📊 Total indexed: {} stories, {} authors, {} collections",
|
||||
stories.size(), authors.size(), collections.size());
|
||||
logger.info("========================================");
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("========================================");
|
||||
logger.error("❌ Bulk reindexing failed", e);
|
||||
logger.error("========================================");
|
||||
// Don't throw the exception - let the application start even if indexing fails
|
||||
// This allows the application to be functional even with search issues
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.storycove.controller;
|
||||
|
||||
import com.storycove.entity.RefreshToken;
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.storycove.service.PasswordAuthenticationService;
|
||||
import com.storycove.service.RefreshTokenService;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -13,59 +19,154 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
|
||||
|
||||
private final PasswordAuthenticationService passwordService;
|
||||
private final LibraryService libraryService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) {
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
|
||||
public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil, RefreshTokenService refreshTokenService) {
|
||||
this.passwordService = passwordService;
|
||||
this.libraryService = libraryService;
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.refreshTokenService = refreshTokenService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
|
||||
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest, HttpServletResponse response) {
|
||||
// Use new library-aware authentication
|
||||
String token = passwordService.authenticateAndSwitchLibrary(request.getPassword());
|
||||
|
||||
|
||||
if (token != null) {
|
||||
// Set httpOnly cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", token)
|
||||
// Get library ID from JWT token
|
||||
String libraryId = jwtUtil.getLibraryIdFromToken(token);
|
||||
|
||||
// Get user agent and IP address for refresh token
|
||||
String userAgent = httpRequest.getHeader("User-Agent");
|
||||
String ipAddress = getClientIpAddress(httpRequest);
|
||||
|
||||
// Create refresh token
|
||||
RefreshToken refreshToken = refreshTokenService.createRefreshToken(libraryId, userAgent, ipAddress);
|
||||
|
||||
// Set access token cookie (24 hours)
|
||||
ResponseCookie accessCookie = ResponseCookie.from("token", token)
|
||||
.httpOnly(true)
|
||||
.secure(false) // Set to true in production with HTTPS
|
||||
.path("/")
|
||||
.maxAge(Duration.ofDays(1))
|
||||
.build();
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
|
||||
|
||||
// Set refresh token cookie (14 days)
|
||||
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken.getToken())
|
||||
.httpOnly(true)
|
||||
.secure(false) // Set to true in production with HTTPS
|
||||
.path("/")
|
||||
.maxAge(Duration.ofDays(14))
|
||||
.build();
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
|
||||
|
||||
String libraryInfo = passwordService.getCurrentLibraryInfo();
|
||||
return ResponseEntity.ok(new LoginResponse("Authentication successful - " + libraryInfo, token));
|
||||
} else {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
|
||||
// Get refresh token from cookie
|
||||
String refreshTokenString = getRefreshTokenFromCookies(request);
|
||||
|
||||
if (refreshTokenString == null) {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Refresh token not found"));
|
||||
}
|
||||
|
||||
// Verify refresh token
|
||||
Optional<RefreshToken> refreshTokenOpt = refreshTokenService.verifyRefreshToken(refreshTokenString);
|
||||
|
||||
if (refreshTokenOpt.isEmpty()) {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Invalid or expired refresh token"));
|
||||
}
|
||||
|
||||
RefreshToken refreshToken = refreshTokenOpt.get();
|
||||
String tokenLibraryId = refreshToken.getLibraryId();
|
||||
|
||||
// Check if we need to switch libraries based on refresh token's library ID
|
||||
try {
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
|
||||
// Switch library if refresh token's library differs from current library
|
||||
// This handles cross-device library switching on token refresh
|
||||
if (tokenLibraryId != null && !tokenLibraryId.equals(currentLibraryId)) {
|
||||
logger.info("Refresh token library '{}' differs from current library '{}', switching libraries",
|
||||
tokenLibraryId, currentLibraryId);
|
||||
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
|
||||
} else if (currentLibraryId == null && tokenLibraryId != null) {
|
||||
// Handle case after backend restart where no library is active
|
||||
logger.info("No active library on refresh, switching to refresh token's library: {}", tokenLibraryId);
|
||||
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to switch library during token refresh: {}", e.getMessage());
|
||||
return ResponseEntity.status(500).body(new ErrorResponse("Failed to switch library: " + e.getMessage()));
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
String newAccessToken = jwtUtil.generateToken("user", tokenLibraryId);
|
||||
|
||||
// Set new access token cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", newAccessToken)
|
||||
.httpOnly(true)
|
||||
.secure(false) // Set to true in production with HTTPS
|
||||
.path("/")
|
||||
.maxAge(Duration.ofDays(1))
|
||||
.build();
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
|
||||
return ResponseEntity.ok(new LoginResponse("Token refreshed successfully", newAccessToken));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
public ResponseEntity<?> logout(HttpServletRequest request, HttpServletResponse response) {
|
||||
// Clear authentication state
|
||||
libraryService.clearAuthentication();
|
||||
|
||||
// Clear the cookie
|
||||
ResponseCookie cookie = ResponseCookie.from("token", "")
|
||||
|
||||
// Revoke refresh token if present
|
||||
String refreshTokenString = getRefreshTokenFromCookies(request);
|
||||
if (refreshTokenString != null) {
|
||||
refreshTokenService.findByToken(refreshTokenString).ifPresent(refreshTokenService::revokeToken);
|
||||
}
|
||||
|
||||
// Clear the access token cookie
|
||||
ResponseCookie accessCookie = ResponseCookie.from("token", "")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.path("/")
|
||||
.maxAge(Duration.ZERO)
|
||||
.build();
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
|
||||
|
||||
// Clear the refresh token cookie
|
||||
ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "")
|
||||
.httpOnly(true)
|
||||
.secure(false)
|
||||
.path("/")
|
||||
.maxAge(Duration.ZERO)
|
||||
.build();
|
||||
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
|
||||
|
||||
return ResponseEntity.ok(new MessageResponse("Logged out successfully"));
|
||||
}
|
||||
|
||||
@@ -77,7 +178,34 @@ public class AuthController {
|
||||
return ResponseEntity.status(401).body(new ErrorResponse("Token is invalid or expired"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper methods
|
||||
private String getRefreshTokenFromCookies(HttpServletRequest request) {
|
||||
if (request.getCookies() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Arrays.stream(request.getCookies())
|
||||
.filter(cookie -> "refreshToken".equals(cookie.getName()))
|
||||
.map(Cookie::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String getClientIpAddress(HttpServletRequest request) {
|
||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||
return xForwardedFor.split(",")[0].trim();
|
||||
}
|
||||
|
||||
String xRealIp = request.getHeader("X-Real-IP");
|
||||
if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||
return xRealIp;
|
||||
}
|
||||
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public static class LoginRequest {
|
||||
@NotBlank(message = "Password is required")
|
||||
|
||||
130
backend/src/main/java/com/storycove/entity/RefreshToken.java
Normal file
130
backend/src/main/java/com/storycove/entity/RefreshToken.java
Normal file
@@ -0,0 +1,130 @@
|
||||
package com.storycove.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "refresh_tokens")
|
||||
public class RefreshToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column
|
||||
private LocalDateTime revokedAt;
|
||||
|
||||
@Column
|
||||
private String libraryId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String userAgent;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String ipAddress;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// Constructors
|
||||
public RefreshToken() {
|
||||
}
|
||||
|
||||
public RefreshToken(String token, LocalDateTime expiresAt, String libraryId, String userAgent, String ipAddress) {
|
||||
this.token = token;
|
||||
this.expiresAt = expiresAt;
|
||||
this.libraryId = libraryId;
|
||||
this.userAgent = userAgent;
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
public void setToken(String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getRevokedAt() {
|
||||
return revokedAt;
|
||||
}
|
||||
|
||||
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||
this.revokedAt = revokedAt;
|
||||
}
|
||||
|
||||
public String getLibraryId() {
|
||||
return libraryId;
|
||||
}
|
||||
|
||||
public void setLibraryId(String libraryId) {
|
||||
this.libraryId = libraryId;
|
||||
}
|
||||
|
||||
public String getUserAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public void setUserAgent(String userAgent) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public String getIpAddress() {
|
||||
return ipAddress;
|
||||
}
|
||||
|
||||
public void setIpAddress(String ipAddress) {
|
||||
this.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
public boolean isExpired() {
|
||||
return LocalDateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean isRevoked() {
|
||||
return revokedAt != null;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return !isExpired() && !isRevoked();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.RefreshToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
|
||||
|
||||
Optional<RefreshToken> findByToken(String token);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :now")
|
||||
void deleteExpiredTokens(@Param("now") LocalDateTime now);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE RefreshToken rt SET rt.revokedAt = :now WHERE rt.libraryId = :libraryId AND rt.revokedAt IS NULL")
|
||||
void revokeAllByLibraryId(@Param("libraryId") String libraryId, @Param("now") LocalDateTime now);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE RefreshToken rt SET rt.revokedAt = :now WHERE rt.revokedAt IS NULL")
|
||||
void revokeAll(@Param("now") LocalDateTime now);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.storycove.security;
|
||||
|
||||
import com.storycove.service.LibraryService;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
@@ -17,11 +20,15 @@ import java.util.ArrayList;
|
||||
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
|
||||
private final LibraryService libraryService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtUtil jwtUtil, LibraryService libraryService) {
|
||||
this.jwtUtil = jwtUtil;
|
||||
this.libraryService = libraryService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -52,9 +59,31 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
|
||||
String subject = jwtUtil.getSubjectFromToken(token);
|
||||
|
||||
|
||||
// Check if we need to switch libraries based on token's library ID
|
||||
try {
|
||||
String tokenLibraryId = jwtUtil.getLibraryIdFromToken(token);
|
||||
String currentLibraryId = libraryService.getCurrentLibraryId();
|
||||
|
||||
// Switch library if token's library differs from current library
|
||||
// This handles cross-device library switching automatically
|
||||
if (tokenLibraryId != null && !tokenLibraryId.equals(currentLibraryId)) {
|
||||
logger.info("Token library '{}' differs from current library '{}', switching libraries",
|
||||
tokenLibraryId, currentLibraryId);
|
||||
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
|
||||
} else if (currentLibraryId == null && tokenLibraryId != null) {
|
||||
// Handle case after backend restart where no library is active
|
||||
logger.info("No active library, switching to token's library: {}", tokenLibraryId);
|
||||
libraryService.switchToLibraryAfterAuthentication(tokenLibraryId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to switch library from token: {}", e.getMessage());
|
||||
// Don't fail the request - authentication can still proceed
|
||||
// but user might see wrong library data until next login
|
||||
}
|
||||
|
||||
if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
UsernamePasswordAuthenticationToken authToken =
|
||||
new UsernamePasswordAuthenticationToken(subject, null, new ArrayList<>());
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.dto.CollectionDto;
|
||||
import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.dto.StoryReadingDto;
|
||||
import com.storycove.dto.TagDto;
|
||||
@@ -50,14 +51,31 @@ public class CollectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search collections using Typesense (MANDATORY for all search/filter operations)
|
||||
* Search collections using Solr (MANDATORY for all search/filter operations)
|
||||
* This method MUST be used instead of JPA queries for listing collections
|
||||
*/
|
||||
public SearchResultDto<Collection> searchCollections(String query, List<String> tags, boolean includeArchived, int page, int limit) {
|
||||
// Collections are currently handled at database level, not indexed in search engine
|
||||
// Return empty result for now as collections search is not implemented in Solr
|
||||
logger.warn("Collections search not yet implemented in Solr, returning empty results");
|
||||
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
|
||||
try {
|
||||
// Use SearchServiceAdapter to search collections
|
||||
SearchResultDto<CollectionDto> searchResult = searchServiceAdapter.searchCollections(query, tags, includeArchived, page, limit);
|
||||
|
||||
// Convert CollectionDto back to Collection entities by fetching from database
|
||||
List<Collection> collections = new ArrayList<>();
|
||||
for (CollectionDto dto : searchResult.getResults()) {
|
||||
try {
|
||||
Collection collection = findByIdBasic(dto.getId());
|
||||
collections.add(collection);
|
||||
} catch (ResourceNotFoundException e) {
|
||||
logger.warn("Collection {} found in search index but not in database", dto.getId());
|
||||
}
|
||||
}
|
||||
|
||||
return new SearchResultDto<>(collections, (int) searchResult.getTotalHits(), page, limit,
|
||||
query != null ? query : "", searchResult.getSearchTimeMs());
|
||||
} catch (Exception e) {
|
||||
logger.error("Collection search failed, falling back to empty results", e);
|
||||
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.entity.RefreshToken;
|
||||
import com.storycove.repository.RefreshTokenRepository;
|
||||
import com.storycove.util.JwtUtil;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class RefreshTokenService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RefreshTokenService.class);
|
||||
|
||||
private final RefreshTokenRepository refreshTokenRepository;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
public RefreshTokenService(RefreshTokenRepository refreshTokenRepository, JwtUtil jwtUtil) {
|
||||
this.refreshTokenRepository = refreshTokenRepository;
|
||||
this.jwtUtil = jwtUtil;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new refresh token
|
||||
*/
|
||||
public RefreshToken createRefreshToken(String libraryId, String userAgent, String ipAddress) {
|
||||
String token = jwtUtil.generateRefreshToken();
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(jwtUtil.getRefreshExpirationMs() / 1000);
|
||||
|
||||
RefreshToken refreshToken = new RefreshToken(token, expiresAt, libraryId, userAgent, ipAddress);
|
||||
return refreshTokenRepository.save(refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a refresh token by its token string
|
||||
*/
|
||||
public Optional<RefreshToken> findByToken(String token) {
|
||||
return refreshTokenRepository.findByToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and validate a refresh token
|
||||
*/
|
||||
public Optional<RefreshToken> verifyRefreshToken(String token) {
|
||||
return refreshTokenRepository.findByToken(token)
|
||||
.filter(RefreshToken::isValid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific refresh token
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeToken(RefreshToken token) {
|
||||
token.setRevokedAt(LocalDateTime.now());
|
||||
refreshTokenRepository.save(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a specific library
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeAllByLibraryId(String libraryId) {
|
||||
refreshTokenRepository.revokeAllByLibraryId(libraryId, LocalDateTime.now());
|
||||
logger.info("Revoked all refresh tokens for library: {}", libraryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens (e.g., for logout all)
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeAll() {
|
||||
refreshTokenRepository.revokeAll(LocalDateTime.now());
|
||||
logger.info("Revoked all refresh tokens");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens periodically
|
||||
* Runs daily at 3 AM
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 * * ?")
|
||||
@Transactional
|
||||
public void cleanupExpiredTokens() {
|
||||
refreshTokenRepository.deleteExpiredTokens(LocalDateTime.now());
|
||||
logger.info("Cleaned up expired refresh tokens");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.dto.AuthorSearchDto;
|
||||
import com.storycove.dto.CollectionDto;
|
||||
import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.dto.StorySearchDto;
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.Story;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -119,6 +121,14 @@ public class SearchServiceAdapter {
|
||||
return solrService.getTagSuggestions(query, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search collections with unified interface
|
||||
*/
|
||||
public SearchResultDto<CollectionDto> searchCollections(String query, List<String> tags,
|
||||
boolean includeArchived, int page, int limit) {
|
||||
return solrService.searchCollections(query, tags, includeArchived, page, limit);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// INDEX OPERATIONS
|
||||
// ===============================
|
||||
@@ -211,6 +221,50 @@ public class SearchServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a collection in Solr
|
||||
*/
|
||||
public void indexCollection(Collection collection) {
|
||||
try {
|
||||
solrService.indexCollection(collection);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to index collection {}", collection.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a collection in Solr
|
||||
*/
|
||||
public void updateCollection(Collection collection) {
|
||||
try {
|
||||
solrService.updateCollection(collection);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to update collection {}", collection.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a collection from Solr
|
||||
*/
|
||||
public void deleteCollection(UUID collectionId) {
|
||||
try {
|
||||
solrService.deleteCollection(collectionId);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to delete collection {}", collectionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk index collections in Solr
|
||||
*/
|
||||
public void bulkIndexCollections(List<Collection> collections) {
|
||||
try {
|
||||
solrService.bulkIndexCollections(collections);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to bulk index {} collections", collections.size(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// UTILITY METHODS
|
||||
// ===============================
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.storycove.service;
|
||||
|
||||
import com.storycove.config.SolrProperties;
|
||||
import com.storycove.dto.AuthorSearchDto;
|
||||
import com.storycove.dto.CollectionDto;
|
||||
import com.storycove.dto.FacetCountDto;
|
||||
import com.storycove.dto.SearchResultDto;
|
||||
import com.storycove.dto.StorySearchDto;
|
||||
import com.storycove.entity.Author;
|
||||
import com.storycove.entity.Collection;
|
||||
import com.storycove.entity.Story;
|
||||
import org.apache.solr.client.solrj.SolrClient;
|
||||
import org.apache.solr.client.solrj.SolrQuery;
|
||||
@@ -63,6 +65,7 @@ public class SolrService {
|
||||
logger.debug("Testing Solr cores availability...");
|
||||
testCoreAvailability(properties.getCores().getStories());
|
||||
testCoreAvailability(properties.getCores().getAuthors());
|
||||
testCoreAvailability(properties.getCores().getCollections());
|
||||
logger.debug("Solr cores are available");
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to test Solr cores availability", e);
|
||||
@@ -190,6 +193,61 @@ public class SolrService {
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// COLLECTION INDEXING
|
||||
// ===============================
|
||||
|
||||
public void indexCollection(Collection collection) throws IOException {
|
||||
if (!isAvailable()) {
|
||||
logger.debug("Solr not available - skipping collection indexing");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Indexing collection: {} ({})", collection.getName(), collection.getId());
|
||||
SolrInputDocument doc = createCollectionDocument(collection);
|
||||
|
||||
UpdateResponse response = solrClient.add(properties.getCores().getCollections(), doc,
|
||||
properties.getCommit().getCommitWithin());
|
||||
|
||||
if (response.getStatus() == 0) {
|
||||
logger.debug("Successfully indexed collection: {}", collection.getId());
|
||||
} else {
|
||||
logger.warn("Collection indexing returned non-zero status: {}", response.getStatus());
|
||||
}
|
||||
} catch (SolrServerException e) {
|
||||
logger.error("Failed to index collection: {}", collection.getId(), e);
|
||||
throw new IOException("Failed to index collection", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCollection(Collection collection) throws IOException {
|
||||
// For Solr, update is the same as index (upsert behavior)
|
||||
indexCollection(collection);
|
||||
}
|
||||
|
||||
public void deleteCollection(UUID collectionId) throws IOException {
|
||||
if (!isAvailable()) {
|
||||
logger.debug("Solr not available - skipping collection deletion");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Deleting collection from index: {}", collectionId);
|
||||
UpdateResponse response = solrClient.deleteById(properties.getCores().getCollections(),
|
||||
collectionId.toString(), properties.getCommit().getCommitWithin());
|
||||
|
||||
if (response.getStatus() == 0) {
|
||||
logger.debug("Successfully deleted collection: {}", collectionId);
|
||||
} else {
|
||||
logger.warn("Collection deletion returned non-zero status: {}", response.getStatus());
|
||||
}
|
||||
} catch (SolrServerException e) {
|
||||
logger.error("Failed to delete collection: {}", collectionId, e);
|
||||
throw new IOException("Failed to delete collection", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BULK OPERATIONS
|
||||
// ===============================
|
||||
@@ -246,6 +304,32 @@ public class SolrService {
|
||||
}
|
||||
}
|
||||
|
||||
public void bulkIndexCollections(List<Collection> collections) throws IOException {
|
||||
if (!isAvailable() || collections.isEmpty()) {
|
||||
logger.debug("Solr not available or empty collections list - skipping bulk indexing");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug("Bulk indexing {} collections", collections.size());
|
||||
List<SolrInputDocument> docs = collections.stream()
|
||||
.map(this::createCollectionDocument)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
UpdateResponse response = solrClient.add(properties.getCores().getCollections(), docs,
|
||||
properties.getCommit().getCommitWithin());
|
||||
|
||||
if (response.getStatus() == 0) {
|
||||
logger.debug("Successfully bulk indexed {} collections", collections.size());
|
||||
} else {
|
||||
logger.warn("Bulk collection indexing returned non-zero status: {}", response.getStatus());
|
||||
}
|
||||
} catch (SolrServerException e) {
|
||||
logger.error("Failed to bulk index collections", e);
|
||||
throw new IOException("Failed to bulk index collections", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// DOCUMENT CREATION
|
||||
// ===============================
|
||||
@@ -349,6 +433,52 @@ public class SolrService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
private SolrInputDocument createCollectionDocument(Collection collection) {
|
||||
SolrInputDocument doc = new SolrInputDocument();
|
||||
|
||||
doc.addField("id", collection.getId().toString());
|
||||
doc.addField("name", collection.getName());
|
||||
doc.addField("description", collection.getDescription());
|
||||
doc.addField("rating", collection.getRating());
|
||||
doc.addField("coverImagePath", collection.getCoverImagePath());
|
||||
doc.addField("isArchived", collection.getIsArchived());
|
||||
|
||||
// Calculate derived fields
|
||||
doc.addField("storyCount", collection.getStoryCount());
|
||||
doc.addField("totalWordCount", collection.getTotalWordCount());
|
||||
doc.addField("estimatedReadingTime", collection.getEstimatedReadingTime());
|
||||
|
||||
Double avgRating = collection.getAverageStoryRating();
|
||||
if (avgRating != null && avgRating > 0) {
|
||||
doc.addField("averageStoryRating", avgRating);
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
if (collection.getTags() != null && !collection.getTags().isEmpty()) {
|
||||
List<String> tagNames = collection.getTags().stream()
|
||||
.map(tag -> tag.getName())
|
||||
.collect(Collectors.toList());
|
||||
doc.addField("tagNames", tagNames);
|
||||
}
|
||||
|
||||
doc.addField("createdAt", formatDateTime(collection.getCreatedAt()));
|
||||
doc.addField("updatedAt", formatDateTime(collection.getUpdatedAt()));
|
||||
|
||||
// Add library ID for multi-tenant separation
|
||||
String currentLibraryId = getCurrentLibraryId();
|
||||
try {
|
||||
if (currentLibraryId != null) {
|
||||
doc.addField("libraryId", currentLibraryId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If libraryId field doesn't exist, log warning and continue without it
|
||||
// This allows indexing to work even if schema migration hasn't completed
|
||||
logger.warn("Could not add libraryId field to document (field may not exist in schema): {}", e.getMessage());
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private String formatDateTime(LocalDateTime dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + "Z";
|
||||
@@ -648,6 +778,67 @@ public class SolrService {
|
||||
}
|
||||
}
|
||||
|
||||
public SearchResultDto<CollectionDto> searchCollections(String query, List<String> tags,
|
||||
boolean includeArchived, int page, int limit) {
|
||||
if (!isAvailable()) {
|
||||
logger.debug("Solr not available - returning empty collection search results");
|
||||
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
|
||||
}
|
||||
|
||||
try {
|
||||
SolrQuery solrQuery = new SolrQuery();
|
||||
|
||||
// Set query
|
||||
if (query == null || query.trim().isEmpty()) {
|
||||
solrQuery.setQuery("*:*");
|
||||
} else {
|
||||
solrQuery.setQuery(query);
|
||||
solrQuery.set("defType", "edismax");
|
||||
solrQuery.set("qf", "name^3.0 description^2.0 tagNames^1.0");
|
||||
}
|
||||
|
||||
// Add library filter for multi-tenant separation
|
||||
String currentLibraryId = getCurrentLibraryId();
|
||||
solrQuery.addFilterQuery("libraryId:\"" + escapeQueryChars(currentLibraryId) + "\"");
|
||||
|
||||
// Tag filters
|
||||
if (tags != null && !tags.isEmpty()) {
|
||||
String tagFilter = tags.stream()
|
||||
.map(tag -> "tagNames:\"" + escapeQueryChars(tag) + "\"")
|
||||
.collect(Collectors.joining(" AND "));
|
||||
solrQuery.addFilterQuery(tagFilter);
|
||||
}
|
||||
|
||||
// Archive filter
|
||||
if (!includeArchived) {
|
||||
solrQuery.addFilterQuery("isArchived:false");
|
||||
}
|
||||
|
||||
// Pagination
|
||||
solrQuery.setStart(page * limit);
|
||||
solrQuery.setRows(limit);
|
||||
|
||||
// Sorting - by name ascending
|
||||
solrQuery.setSort("name", SolrQuery.ORDER.asc);
|
||||
|
||||
// Explicitly disable faceting
|
||||
solrQuery.setFacet(false);
|
||||
|
||||
logger.info("SolrService: Executing Collection search query: {}", solrQuery);
|
||||
|
||||
QueryResponse response = solrClient.query(properties.getCores().getCollections(), solrQuery);
|
||||
|
||||
logger.info("SolrService: Collection query executed successfully, found {} results",
|
||||
response.getResults().getNumFound());
|
||||
|
||||
return buildCollectionSearchResult(response, page, limit, query);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Collection search failed for query: {}", query, e);
|
||||
return new SearchResultDto<>(new ArrayList<>(), 0, page, limit, query != null ? query : "", 0);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getTagSuggestions(String query, int limit) {
|
||||
if (!isAvailable()) {
|
||||
return Collections.emptyList();
|
||||
@@ -762,6 +953,19 @@ public class SolrService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private SearchResultDto<CollectionDto> buildCollectionSearchResult(QueryResponse response, int page, int limit, String query) {
|
||||
SolrDocumentList results = response.getResults();
|
||||
List<CollectionDto> collections = new ArrayList<>();
|
||||
|
||||
for (SolrDocument doc : results) {
|
||||
CollectionDto collection = convertToCollectionDto(doc);
|
||||
collections.add(collection);
|
||||
}
|
||||
|
||||
return new SearchResultDto<>(collections, (int) results.getNumFound(), page, limit,
|
||||
query != null ? query : "", 0);
|
||||
}
|
||||
|
||||
private StorySearchDto convertToStorySearchDto(SolrDocument doc) {
|
||||
StorySearchDto story = new StorySearchDto();
|
||||
|
||||
@@ -797,7 +1001,7 @@ public class SolrService {
|
||||
story.setSeriesName((String) doc.getFieldValue("seriesName"));
|
||||
|
||||
// Handle tags
|
||||
Collection<Object> tagValues = doc.getFieldValues("tagNames");
|
||||
java.util.Collection<Object> tagValues = doc.getFieldValues("tagNames");
|
||||
if (tagValues != null) {
|
||||
List<String> tagNames = tagValues.stream()
|
||||
.map(Object::toString)
|
||||
@@ -824,7 +1028,7 @@ public class SolrService {
|
||||
}
|
||||
|
||||
// Handle URLs
|
||||
Collection<Object> urlValues = doc.getFieldValues("urls");
|
||||
java.util.Collection<Object> urlValues = doc.getFieldValues("urls");
|
||||
if (urlValues != null) {
|
||||
List<String> urls = urlValues.stream()
|
||||
.map(Object::toString)
|
||||
@@ -839,6 +1043,40 @@ public class SolrService {
|
||||
return author;
|
||||
}
|
||||
|
||||
private CollectionDto convertToCollectionDto(SolrDocument doc) {
|
||||
CollectionDto collection = new CollectionDto();
|
||||
|
||||
collection.setId(UUID.fromString((String) doc.getFieldValue("id")));
|
||||
collection.setName((String) doc.getFieldValue("name"));
|
||||
collection.setDescription((String) doc.getFieldValue("description"));
|
||||
collection.setRating((Integer) doc.getFieldValue("rating"));
|
||||
collection.setCoverImagePath((String) doc.getFieldValue("coverImagePath"));
|
||||
collection.setIsArchived((Boolean) doc.getFieldValue("isArchived"));
|
||||
collection.setStoryCount((Integer) doc.getFieldValue("storyCount"));
|
||||
collection.setTotalWordCount((Integer) doc.getFieldValue("totalWordCount"));
|
||||
collection.setEstimatedReadingTime((Integer) doc.getFieldValue("estimatedReadingTime"));
|
||||
|
||||
Double avgRating = (Double) doc.getFieldValue("averageStoryRating");
|
||||
if (avgRating != null) {
|
||||
collection.setAverageStoryRating(avgRating);
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
java.util.Collection<Object> tagValues = doc.getFieldValues("tagNames");
|
||||
if (tagValues != null) {
|
||||
List<String> tagNames = tagValues.stream()
|
||||
.map(Object::toString)
|
||||
.collect(Collectors.toList());
|
||||
collection.setTagNames(tagNames);
|
||||
}
|
||||
|
||||
// Handle dates
|
||||
collection.setCreatedAt(parseDateTimeFromSolr(doc.getFieldValue("createdAt")));
|
||||
collection.setUpdatedAt(parseDateTimeFromSolr(doc.getFieldValue("updatedAt")));
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
private LocalDateTime parseDateTime(String dateStr) {
|
||||
if (dateStr == null || dateStr.isEmpty()) {
|
||||
return null;
|
||||
|
||||
@@ -16,15 +16,18 @@ import java.util.Date;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class);
|
||||
|
||||
|
||||
// Security: Generate new secret on each startup to invalidate all existing tokens
|
||||
private String secret;
|
||||
|
||||
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
|
||||
|
||||
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default (access token)
|
||||
private Long expiration;
|
||||
|
||||
|
||||
@Value("${storycove.jwt.refresh-expiration:1209600000}") // 14 days default (refresh token)
|
||||
private Long refreshExpiration;
|
||||
|
||||
@PostConstruct
|
||||
public void initialize() {
|
||||
// Generate a new random secret on startup to invalidate all existing JWT tokens
|
||||
@@ -33,10 +36,21 @@ public class JwtUtil {
|
||||
byte[] secretBytes = new byte[64]; // 512 bits
|
||||
random.nextBytes(secretBytes);
|
||||
this.secret = Base64.getEncoder().encodeToString(secretBytes);
|
||||
|
||||
|
||||
logger.info("JWT secret rotated on startup - all existing tokens invalidated");
|
||||
logger.info("Users will need to re-authenticate after application restart for security");
|
||||
}
|
||||
|
||||
public Long getRefreshExpirationMs() {
|
||||
return refreshExpiration;
|
||||
}
|
||||
|
||||
public String generateRefreshToken() {
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] tokenBytes = new byte[32]; // 256 bits
|
||||
random.nextBytes(tokenBytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
}
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
return Keys.hmacShaKeyFor(secret.getBytes());
|
||||
|
||||
@@ -42,7 +42,8 @@ storycove:
|
||||
allowed-origins: ${STORYCOVE_CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:6925}
|
||||
jwt:
|
||||
secret: ${JWT_SECRET} # REQUIRED: Must be at least 32 characters, no default for security
|
||||
expiration: 86400000 # 24 hours
|
||||
expiration: 86400000 # 24 hours (access token)
|
||||
refresh-expiration: 1209600000 # 14 days (refresh token)
|
||||
auth:
|
||||
password: ${APP_PASSWORD} # REQUIRED: No default password for security
|
||||
search:
|
||||
|
||||
Reference in New Issue
Block a user