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.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) {

View File

@@ -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<Session, Long> {
Optional<Session> 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<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
@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 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
@@ -24,12 +27,18 @@ public class SessionService {
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();
@@ -55,6 +67,32 @@ public class SessionService {
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.
* Returns empty if session is invalid or expired.
@@ -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;
}
}

View File

@@ -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

View File

@@ -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;