diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/UserApplication.java b/patbond-user/src/main/java/com/patbond/patbond/user/UserApplication.java index 3142542..5973dff 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/UserApplication.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/UserApplication.java @@ -2,7 +2,9 @@ package com.patbond.patbond.user; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class UserApplication { diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java b/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java index 6a5e3c4..1015ff6 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java @@ -64,6 +64,16 @@ public class UserSecurityProperties { /** Refresh token lifetime (ADR-003: 30 days). */ private Duration refreshTtl = Duration.ofDays(30); + /** + * How long dead rows (revoked or past expiry) stay in + * identity.auth_sessions before the cleanup job deletes them. + * Revoked rows are what refresh-token reuse detection matches + * against, so this is also the reuse-detection window: after it, a + * replayed token is merely "unknown" (still rejected with 40102, but + * without the family-revocation escalation). + */ + private Duration cleanupRetention = Duration.ofDays(30); + public Duration getRefreshTtl() { return refreshTtl; } @@ -71,6 +81,14 @@ public class UserSecurityProperties { public void setRefreshTtl(Duration refreshTtl) { this.refreshTtl = refreshTtl; } + + public Duration getCleanupRetention() { + return cleanupRetention; + } + + public void setCleanupRetention(Duration cleanupRetention) { + this.cleanupRetention = cleanupRetention; + } } public static class LoginLock { diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionCleanupJob.java b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionCleanupJob.java new file mode 100644 index 0000000..7eb80c1 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionCleanupJob.java @@ -0,0 +1,42 @@ +package com.patbond.patbond.user.session; + +import com.patbond.patbond.user.config.UserSecurityProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Periodically deletes dead identity.auth_sessions rows (report 16 §9.3): + * without it, every rotation and logout leaves a row behind forever. Rows are + * kept for the configured retention after death because revoked rows are what + * refresh-token reuse detection matches against — see + * {@link UserSecurityProperties.Session#getCleanupRetention()}. + * + *
The delete is idempotent and safe to run on several instances at once;
+ * an occasional overlap just deletes zero extra rows.
+ */
+@Component
+public class SessionCleanupJob {
+
+ private static final Logger log = LoggerFactory.getLogger(SessionCleanupJob.class);
+
+ private final SessionRepository sessionRepository;
+ private final UserSecurityProperties properties;
+
+ public SessionCleanupJob(SessionRepository sessionRepository, UserSecurityProperties properties) {
+ this.sessionRepository = sessionRepository;
+ this.properties = properties;
+ }
+
+ @Scheduled(
+ fixedDelayString = "${patbond.session.cleanup-interval:PT6H}",
+ initialDelayString = "${patbond.session.cleanup-initial-delay:PT10M}")
+ public void cleanUp() {
+ int deleted = sessionRepository.deleteDeadSessionsOlderThan(
+ properties.getSession().getCleanupRetention().toSeconds());
+ if (deleted > 0) {
+ log.info("Session cleanup removed {} dead auth_sessions rows", deleted);
+ }
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java
index c85f38d..bd88f8a 100644
--- a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java
@@ -106,4 +106,20 @@ public class SessionRepository {
.param("reason", reason)
.update();
}
+
+ /**
+ * Deletes rows that have been dead (revoked, or past expiry) longer than
+ * the retention period. Rotation-chain links into deleted rows are
+ * released by the ON DELETE SET NULL on replaced_by_session_id.
+ */
+ public int deleteDeadSessionsOlderThan(long retentionSeconds) {
+ return jdbcClient.sql("""
+ DELETE FROM identity.auth_sessions
+ WHERE (revoked_at IS NOT NULL
+ AND revoked_at < now() - make_interval(secs => :retentionSeconds))
+ OR expires_at < now() - make_interval(secs => :retentionSeconds)
+ """)
+ .param("retentionSeconds", retentionSeconds)
+ .update();
+ }
}
diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample
index fb2fe9c..dcc1423 100644
--- a/patbond-user/src/main/resources/application.yml.sample
+++ b/patbond-user/src/main/resources/application.yml.sample
@@ -26,6 +26,12 @@ patbond:
session:
# ADR-003:refresh token 30 天,刷新即轮换;值可配置。
refresh-ttl: ${PATBOND_REFRESH_TTL:30d}
+ # 死亡(撤销/过期)会话行的保留期,过后由定时任务删除。已撤销行是
+ # refresh token 重用检测的比对对象,因此该值同时是重用检测窗口。
+ cleanup-retention: ${PATBOND_SESSION_CLEANUP_RETENTION:30d}
+ # 清理任务节奏(Spring Duration 表达式)
+ cleanup-interval: ${PATBOND_SESSION_CLEANUP_INTERVAL:PT6H}
+ cleanup-initial-delay: ${PATBOND_SESSION_CLEANUP_INITIAL_DELAY:PT10M}
login-lock:
# 登录失败限制:窗口内连续失败达到阈值后锁定账号(返回 423/42300)。
max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionCleanupIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionCleanupIntegrationTest.java
new file mode 100644
index 0000000..5d54841
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionCleanupIntegrationTest.java
@@ -0,0 +1,79 @@
+package com.patbond.patbond.user.session;
+
+import com.patbond.patbond.common.session.CreateSessionRequest;
+import com.patbond.patbond.common.session.SessionTokens;
+import com.patbond.patbond.common.user.CreateUserRequest;
+import com.patbond.patbond.user.TestcontainersConfiguration;
+import com.patbond.patbond.user.service.UserService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.simple.JdbcClient;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The cleanup job removes rows dead longer than the retention window and
+ * nothing else: live sessions and recently revoked rows (still needed for
+ * reuse detection) survive.
+ */
+@SpringBootTest
+@Import(TestcontainersConfiguration.class)
+class SessionCleanupIntegrationTest {
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private SessionService sessionService;
+
+ @Autowired
+ private SessionCleanupJob cleanupJob;
+
+ @Autowired
+ private JdbcClient jdbcClient;
+
+ @Test
+ void removesOnlySessionsDeadLongerThanRetention() {
+ UUID userId = userService.createUser(
+ new CreateUserRequest("cleanup_user", "secret123", null, null)).getId();
+
+ SessionTokens live = createSession(userId);
+ SessionTokens recentlyRevoked = createSession(userId);
+ SessionTokens longRevoked = createSession(userId);
+ SessionTokens longExpired = createSession(userId);
+
+ sessionService.revoke(userId, recentlyRevoked.getRefreshToken());
+ sessionService.revoke(userId, longRevoked.getRefreshToken());
+ // Backdate beyond the 30-day retention (created_at moves along so the
+ // ck_sessions_expiry / ck_sessions_revoked_at ordering checks hold).
+ backdate("created_at = now() - interval '40 days', revoked_at = now() - interval '31 days'",
+ longRevoked.getSessionId());
+ backdate("created_at = now() - interval '61 days', expires_at = now() - interval '31 days'",
+ longExpired.getSessionId());
+
+ cleanupJob.cleanUp();
+
+ List