added limits and cleanup for sessions
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user