feat: auth_sessions 死亡行定时清理任务(报告 16 遗留 §9.3)
CI / backend-test (push) Failing after 8m10s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:45:38 +08:00
parent 3f6e8187cf
commit 6528a06c28
6 changed files with 163 additions and 0 deletions
@@ -2,7 +2,9 @@ package com.patbond.patbond.user;
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.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication @SpringBootApplication
public class UserApplication { public class UserApplication {
@@ -64,6 +64,16 @@ public class UserSecurityProperties {
/** Refresh token lifetime (ADR-003: 30 days). */ /** Refresh token lifetime (ADR-003: 30 days). */
private Duration refreshTtl = Duration.ofDays(30); 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() { public Duration getRefreshTtl() {
return refreshTtl; return refreshTtl;
} }
@@ -71,6 +81,14 @@ public class UserSecurityProperties {
public void setRefreshTtl(Duration refreshTtl) { public void setRefreshTtl(Duration refreshTtl) {
this.refreshTtl = refreshTtl; this.refreshTtl = refreshTtl;
} }
public Duration getCleanupRetention() {
return cleanupRetention;
}
public void setCleanupRetention(Duration cleanupRetention) {
this.cleanupRetention = cleanupRetention;
}
} }
public static class LoginLock { public static class LoginLock {
@@ -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()}.
*
* <p>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);
}
}
}
@@ -106,4 +106,20 @@ public class SessionRepository {
.param("reason", reason) .param("reason", reason)
.update(); .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();
}
} }
@@ -26,6 +26,12 @@ patbond:
session: session:
# ADR-003refresh token 30 天,刷新即轮换;值可配置。 # ADR-003refresh token 30 天,刷新即轮换;值可配置。
refresh-ttl: ${PATBOND_REFRESH_TTL:30d} 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: login-lock:
# 登录失败限制:窗口内连续失败达到阈值后锁定账号(返回 423/42300)。 # 登录失败限制:窗口内连续失败达到阈值后锁定账号(返回 423/42300)。
max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5} max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5}
@@ -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<UUID> 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();
}
}