From 6528a06c2825cee05d2537d6d6aecfacfd4ac357 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 15:45:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20auth=5Fsessions=20=E6=AD=BB=E4=BA=A1?= =?UTF-8?q?=E8=A1=8C=E5=AE=9A=E6=97=B6=E6=B8=85=E7=90=86=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=EF=BC=88=E6=8A=A5=E5=91=8A=2016=20=E9=81=97=E7=95=99=20=C2=A79?= =?UTF-8?q?.3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SessionCleanupJob(@Scheduled,间隔/初始延迟/保留期均配置项,默认 6h/10m/30d)删除撤销或过期超过保留期的会话行;保留期即重用检测窗口(已撤销行是重用比对对象),注释与 sample 已说明 - 多实例并发安全(幂等 DELETE);轮换链 FK 由 ON DELETE SET NULL 释放 - 门禁:./mvnw clean test → BUILD SUCCESS,75 测试 0 失败(新增 SessionCleanupIntegrationTest:仅删超期死亡行,存活与近期撤销行保留) Co-Authored-By: Claude Fable 5 --- .../patbond/patbond/user/UserApplication.java | 2 + .../user/config/UserSecurityProperties.java | 18 +++++ .../user/session/SessionCleanupJob.java | 42 ++++++++++ .../user/session/SessionRepository.java | 16 ++++ .../src/main/resources/application.yml.sample | 6 ++ .../SessionCleanupIntegrationTest.java | 79 +++++++++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/session/SessionCleanupJob.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/session/SessionCleanupIntegrationTest.java 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 remaining = jdbcClient.sql( + "SELECT id FROM identity.auth_sessions WHERE user_id = :userId") + .param("userId", userId) + .query(UUID.class) + .list(); + assertThat(remaining) + .containsExactlyInAnyOrder(live.getSessionId(), recentlyRevoked.getSessionId()); + } + + private SessionTokens createSession(UUID userId) { + return sessionService.create(new CreateSessionRequest(userId, null, null, null)); + } + + private void backdate(String setClause, UUID sessionId) { + jdbcClient.sql("UPDATE identity.auth_sessions SET " + setClause + " WHERE id = :id") + .param("id", sessionId) + .update(); + } +}