- 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.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 {
|
||||||
|
|
||||||
|
|||||||
+18
@@ -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-003:refresh token 30 天,刷新即轮换;值可配置。
|
# ADR-003:refresh 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}
|
||||||
|
|||||||
+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