added limits and cleanup for sessions

This commit is contained in:
AddictionGames
2026-01-09 21:35:52 +02:00
parent 4c11a204eb
commit 896566738a
6 changed files with 147 additions and 5 deletions

View File

@@ -5,8 +5,10 @@ import com.honey.honey.config.TelegramProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties({TelegramProperties.class}) @EnableConfigurationProperties({TelegramProperties.class})
public class HoneyBackendApplication { public class HoneyBackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -1,6 +1,7 @@
package com.honey.honey.repository; package com.honey.honey.repository;
import com.honey.honey.model.Session; 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.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -8,15 +9,34 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface SessionRepository extends JpaRepository<Session, Long> { public interface SessionRepository extends JpaRepository<Session, Long> {
Optional<Session> findBySessionIdHash(String sessionIdHash); Optional<Session> findBySessionIdHash(String sessionIdHash);
@Modifying /**
@Query("DELETE FROM Session s WHERE s.expiresAt < :now") * Counts active (non-expired) sessions for a user.
void deleteExpiredSessions(@Param("now") LocalDateTime now); */
@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<Session> 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 @Modifying
@Query("DELETE FROM Session s WHERE s.sessionIdHash = :sessionIdHash") @Query("DELETE FROM Session s WHERE s.sessionIdHash = :sessionIdHash")

View File

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

View File

@@ -5,6 +5,8 @@ import com.honey.honey.model.User;
import com.honey.honey.repository.SessionRepository; import com.honey.honey.repository.SessionRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -13,6 +15,7 @@ import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@@ -23,13 +26,19 @@ public class SessionService {
private final SessionRepository sessionRepository; private final SessionRepository sessionRepository;
private static final int SESSION_TTL_HOURS = 24; // 1 day private static final int SESSION_TTL_HOURS = 24; // 1 day
private static final SecureRandom secureRandom = new SecureRandom(); 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. * 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. * Returns the raw session ID (to be sent to frontend) and stores the hash in DB.
*/ */
@Transactional @Transactional
public String createSession(User user) { public String createSession(User user) {
LocalDateTime now = LocalDateTime.now();
// Generate cryptographically random session ID // Generate cryptographically random session ID
byte[] randomBytes = new byte[32]; byte[] randomBytes = new byte[32];
secureRandom.nextBytes(randomBytes); secureRandom.nextBytes(randomBytes);
@@ -39,13 +48,16 @@ public class SessionService {
String sessionIdHash = hashSessionId(sessionId); String sessionIdHash = hashSessionId(sessionId);
// Calculate expiration // 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 // Create and save session
Session session = Session.builder() Session session = Session.builder()
.sessionIdHash(sessionIdHash) .sessionIdHash(sessionIdHash)
.user(user) .user(user)
.createdAt(LocalDateTime.now()) .createdAt(now)
.expiresAt(expiresAt) .expiresAt(expiresAt)
.build(); .build();
@@ -54,6 +66,32 @@ public class SessionService {
log.info("Created session for userId={}, expiresAt={}", user.getId(), expiresAt); log.info("Created session for userId={}, expiresAt={}", user.getId(), expiresAt);
return sessionId; 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<Session> 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. * Validates a session ID and returns the associated user.
@@ -134,5 +172,12 @@ public class SessionService {
public int getSessionTtlSeconds() { public int getSessionTtlSeconds() {
return SESSION_TTL_HOURS * 3600; return SESSION_TTL_HOURS * 3600;
} }
/**
* Gets max active sessions per user.
*/
public int getMaxActiveSessionsPerUser() {
return maxActiveSessionsPerUser;
}
} }

View File

@@ -35,6 +35,17 @@ spring:
telegram: telegram:
bot-token: ${TELEGRAM_BOT_TOKEN} 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: logging:
level: level:
root: INFO root: INFO

View File

@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS sessions (
INDEX idx_session_hash (session_id_hash), INDEX idx_session_hash (session_id_hash),
INDEX idx_expires_at (expires_at), INDEX idx_expires_at (expires_at),
INDEX idx_user_id (user_id), INDEX idx_user_id (user_id),
INDEX idx_user_created (user_id, created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;