- 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:
@@ -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 {
|
||||
|
||||
|
||||
+18
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
.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:
|
||||
# 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}
|
||||
|
||||
+79
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user