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.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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user