diff --git a/src/main/java/com/honey/honey/HoneyBackendApplication.java b/src/main/java/com/honey/honey/HoneyBackendApplication.java index bc42733..5cb5365 100644 --- a/src/main/java/com/honey/honey/HoneyBackendApplication.java +++ b/src/main/java/com/honey/honey/HoneyBackendApplication.java @@ -5,8 +5,10 @@ import com.honey.honey.config.TelegramProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling @EnableConfigurationProperties({TelegramProperties.class}) public class HoneyBackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/honey/honey/repository/SessionRepository.java b/src/main/java/com/honey/honey/repository/SessionRepository.java index 34e72d4..8939053 100644 --- a/src/main/java/com/honey/honey/repository/SessionRepository.java +++ b/src/main/java/com/honey/honey/repository/SessionRepository.java @@ -1,6 +1,7 @@ package com.honey.honey.repository; import com.honey.honey.model.Session; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -8,15 +9,34 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository public interface SessionRepository extends JpaRepository { Optional findBySessionIdHash(String sessionIdHash); - @Modifying - @Query("DELETE FROM Session s WHERE s.expiresAt < :now") - void deleteExpiredSessions(@Param("now") LocalDateTime now); + /** + * Counts active (non-expired) sessions for a user. + */ + @Query("SELECT COUNT(s) FROM Session s WHERE s.user.id = :userId AND s.expiresAt > :now") + long countActiveSessionsByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now); + + /** + * Finds oldest active sessions for a user, ordered by created_at ASC. + * Used to delete oldest sessions when max limit is exceeded. + */ + @Query("SELECT s FROM Session s WHERE s.user.id = :userId AND s.expiresAt > :now ORDER BY s.createdAt ASC") + List findOldestActiveSessionsByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now, Pageable pageable); + + /** + * Batch deletes expired sessions (up to batchSize). + * Returns the number of deleted rows. + * Note: MySQL requires LIMIT to be a literal or bound parameter, so we use a native query. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "DELETE FROM sessions WHERE expires_at < :now LIMIT :batchSize", nativeQuery = true) + int deleteExpiredSessionsBatch(@Param("now") LocalDateTime now, @Param("batchSize") int batchSize); @Modifying @Query("DELETE FROM Session s WHERE s.sessionIdHash = :sessionIdHash") diff --git a/src/main/java/com/honey/honey/service/SessionCleanupService.java b/src/main/java/com/honey/honey/service/SessionCleanupService.java new file mode 100644 index 0000000..c9a9862 --- /dev/null +++ b/src/main/java/com/honey/honey/service/SessionCleanupService.java @@ -0,0 +1,63 @@ +package com.honey.honey.service; + +import com.honey.honey.repository.SessionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * Scheduled service for batch cleanup of expired sessions. + * Runs every minute to delete expired sessions in batches to avoid long transactions. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SessionCleanupService { + + private final SessionRepository sessionRepository; + + @Value("${app.session.cleanup.batch-size:5000}") + private int batchSize; + + @Value("${app.session.cleanup.max-batches-per-run:20}") + private int maxBatchesPerRun; + + /** + * Batch deletes expired sessions. + * Runs every minute at second 0. + * Processes up to MAX_BATCHES_PER_RUN batches per run. + */ + @Scheduled(cron = "0 * * * * ?") // Every minute at second 0 + @Transactional + public void cleanupExpiredSessions() { + LocalDateTime now = LocalDateTime.now(); + int totalDeleted = 0; + int batchesProcessed = 0; + + log.info("Starting expired session cleanup (batchSize={}, maxBatches={})", batchSize, maxBatchesPerRun); + + while (batchesProcessed < maxBatchesPerRun) { + int deleted = sessionRepository.deleteExpiredSessionsBatch(now, batchSize); + totalDeleted += deleted; + batchesProcessed++; + + // If we deleted less than batch size, we've caught up + if (deleted < batchSize) { + break; + } + } + + if (totalDeleted > 0) { + log.info("Session cleanup completed: deleted {} expired session(s) in {} batch(es)", + totalDeleted, batchesProcessed); + } else { + log.debug("Session cleanup completed: no expired sessions found"); + } + } +} + diff --git a/src/main/java/com/honey/honey/service/SessionService.java b/src/main/java/com/honey/honey/service/SessionService.java index 724d11d..c23572b 100644 --- a/src/main/java/com/honey/honey/service/SessionService.java +++ b/src/main/java/com/honey/honey/service/SessionService.java @@ -5,6 +5,8 @@ import com.honey.honey.model.User; import com.honey.honey.repository.SessionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,6 +15,7 @@ import java.security.MessageDigest; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; +import java.util.List; import java.util.Optional; @Slf4j @@ -23,13 +26,19 @@ public class SessionService { private final SessionRepository sessionRepository; private static final int SESSION_TTL_HOURS = 24; // 1 day private static final SecureRandom secureRandom = new SecureRandom(); + + @Value("${app.session.max-active-per-user:5}") + private int maxActiveSessionsPerUser; /** * Creates a new session for a user. + * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest active sessions if limit exceeded. * Returns the raw session ID (to be sent to frontend) and stores the hash in DB. */ @Transactional public String createSession(User user) { + LocalDateTime now = LocalDateTime.now(); + // Generate cryptographically random session ID byte[] randomBytes = new byte[32]; secureRandom.nextBytes(randomBytes); @@ -39,13 +48,16 @@ public class SessionService { String sessionIdHash = hashSessionId(sessionId); // Calculate expiration - LocalDateTime expiresAt = LocalDateTime.now().plusHours(SESSION_TTL_HOURS); + LocalDateTime expiresAt = now.plusHours(SESSION_TTL_HOURS); + + // Enforce max active sessions per user + enforceMaxActiveSessions(user.getId(), now); // Create and save session Session session = Session.builder() .sessionIdHash(sessionIdHash) .user(user) - .createdAt(LocalDateTime.now()) + .createdAt(now) .expiresAt(expiresAt) .build(); @@ -54,6 +66,32 @@ public class SessionService { log.info("Created session for userId={}, expiresAt={}", user.getId(), expiresAt); return sessionId; } + + /** + * Enforces MAX_ACTIVE_SESSIONS_PER_USER by deleting oldest active sessions if limit exceeded. + */ + private void enforceMaxActiveSessions(Long userId, LocalDateTime now) { + long activeCount = sessionRepository.countActiveSessionsByUserId(userId, now); + + if (activeCount >= maxActiveSessionsPerUser) { + // Calculate how many to delete + int toDelete = (int) (activeCount - maxActiveSessionsPerUser + 1); + + // Get oldest active sessions + List oldestSessions = sessionRepository.findOldestActiveSessionsByUserId( + userId, + now, + PageRequest.of(0, toDelete) + ); + + // Delete oldest sessions + if (!oldestSessions.isEmpty()) { + sessionRepository.deleteAll(oldestSessions); + log.info("Deleted {} oldest active session(s) for userId={} to enforce max limit of {}", + oldestSessions.size(), userId, maxActiveSessionsPerUser); + } + } + } /** * Validates a session ID and returns the associated user. @@ -134,5 +172,12 @@ public class SessionService { public int getSessionTtlSeconds() { return SESSION_TTL_HOURS * 3600; } + + /** + * Gets max active sessions per user. + */ + public int getMaxActiveSessionsPerUser() { + return maxActiveSessionsPerUser; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 434eba0..2166dd6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -35,6 +35,17 @@ spring: telegram: bot-token: ${TELEGRAM_BOT_TOKEN} +app: + session: + # Maximum number of active sessions per user (multi-device support) + max-active-per-user: ${APP_SESSION_MAX_ACTIVE_PER_USER:5} + # Batch cleanup configuration + cleanup: + # Number of expired sessions to delete per batch + batch-size: ${APP_SESSION_CLEANUP_BATCH_SIZE:5000} + # Maximum number of batches to process per cleanup run + max-batches-per-run: ${APP_SESSION_CLEANUP_MAX_BATCHES:20} + logging: level: root: INFO diff --git a/src/main/resources/db/migration/V2__create_sessions_table.sql b/src/main/resources/db/migration/V2__create_sessions_table.sql index 8c185b0..f8e20a9 100644 --- a/src/main/resources/db/migration/V2__create_sessions_table.sql +++ b/src/main/resources/db/migration/V2__create_sessions_table.sql @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions ( INDEX idx_session_hash (session_id_hash), INDEX idx_expires_at (expires_at), INDEX idx_user_id (user_id), + INDEX idx_user_created (user_id, created_at), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;