feat: закрыть остаточный backlog

- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
This commit is contained in:
2026-10-28 19:29:23 +03:00
parent d45137220c
commit 1e1e9ae0e4
64 changed files with 2124 additions and 372 deletions

View File

@@ -18,5 +18,6 @@ class AuditController(
@RequestParam(defaultValue = "100") limit: Int,
@RequestParam(required = false) action: String?,
@RequestParam(defaultValue = "0") page: Int,
): List<AuditEventDto> = auditService.list(limit, action, page)
@RequestParam(required = false) cursor: String?,
): List<AuditEventDto> = auditService.list(limit, action, page, cursor)
}

View File

@@ -24,4 +24,6 @@ data class AuditEventDto(
val courseId: String?,
val studentId: String?,
val details: String,
/** Непрозрачный курсор для следующей keyset-страницы. */
val cursor: String? = null,
)

View File

@@ -2,6 +2,9 @@ package ru.ysu.lms.identity.audit
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
import java.util.UUID
interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
@@ -13,4 +16,20 @@ interface AuditEventRepository : JpaRepository<AuditEvent, UUID> {
action: String,
pageable: Pageable,
): List<AuditEvent>
/** Keyset-страница: строго после курсорной пары `(occurredAt, id)` при сортировке DESC. */
@Query(
"""
select e from AuditEvent e
where (:action is null or e.action = :action)
and (e.occurredAt < :beforeAt or (e.occurredAt = :beforeAt and e.id < :beforeId))
order by e.occurredAt desc, e.id desc
""",
)
fun findBefore(
@Param("action") action: String?,
@Param("beforeAt") beforeAt: Instant,
@Param("beforeId") beforeId: UUID,
pageable: Pageable,
): List<AuditEvent>
}

View File

@@ -4,6 +4,8 @@ import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import java.time.Instant
import java.util.Base64
import java.util.UUID
/**
@@ -39,16 +41,22 @@ class AuditService(
limit: Int = DEFAULT_LIMIT,
action: String? = null,
page: Int = 0,
cursor: String? = null,
): List<AuditEventDto> {
val size = limit.coerceIn(1, MAX_LIMIT)
val pageIndex = page.coerceAtLeast(0)
val pageable = PageRequest.of(pageIndex, size)
val filter = action?.trim()?.takeIf { it.isNotEmpty() }
val found =
if (filter == null) {
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
if (cursor != null) {
val (beforeAt, beforeId) = decodeCursor(cursor)
events.findBefore(filter, beforeAt, beforeId, PageRequest.of(0, size))
} else {
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
val pageIndex = page.coerceAtLeast(0)
val pageable = PageRequest.of(pageIndex, size)
if (filter == null) {
events.findAllByOrderByOccurredAtDescIdDesc(pageable)
} else {
events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable)
}
}
return found.map(::toDto)
}
@@ -65,8 +73,30 @@ class AuditService(
courseId = event.courseId?.toString(),
studentId = event.studentId?.toString(),
details = event.details,
cursor = encodeCursor(event.occurredAt, event.id),
)
/** Курсор — URL-safe Base64 без паддинга от пары `occurredAt|id`. */
private fun encodeCursor(
occurredAt: Instant,
id: UUID,
): String =
Base64
.getUrlEncoder()
.withoutPadding()
.encodeToString("$occurredAt|$id".toByteArray(Charsets.UTF_8))
private fun decodeCursor(raw: String): Pair<Instant, UUID> {
val parts =
runCatching { String(Base64.getUrlDecoder().decode(raw), Charsets.UTF_8) }
.getOrNull()
?.split("|", limit = 2)
val occurredAt = parts?.getOrNull(0)?.let { runCatching { Instant.parse(it) }.getOrNull() }
val id = parts?.getOrNull(1)?.let { runCatching { UUID.fromString(it) }.getOrNull() }
if (occurredAt == null || id == null) throw BadRequestException(INVALID_CURSOR)
return occurredAt to id
}
private fun requireText(
raw: String,
field: String,
@@ -97,5 +127,6 @@ class AuditService(
const val ENTITY_ID_MAX = 128
const val ACTOR_LOGIN_MAX = 100
const val DETAILS_MAX = 2000
const val INVALID_CURSOR = "Некорректный курсор аудита"
}
}

View File

@@ -26,6 +26,8 @@ class RefreshToken(
var issuedAt: Instant = Instant.now(),
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant = Instant.now(),
@Column(name = "absolute_expires_at", nullable = false)
var absoluteExpiresAt: Instant = Instant.now(),
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by")

View File

@@ -1,10 +1,21 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
import java.util.UUID
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
fun findByTokenHash(tokenHash: String): RefreshToken?
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<RefreshToken>
/** Удаляет и sliding-, и absolute-мёртвые токены, чтобы цепочка не жила лишние дни. */
@Modifying
@Query("delete from RefreshToken t where t.expiresAt < :cutoff or t.absoluteExpiresAt < :cutoff")
fun deleteExpiredBefore(
@Param("cutoff") cutoff: Instant,
): Int
}

View File

@@ -1,8 +1,11 @@
package ru.ysu.lms.identity.auth.refresh
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Lazy
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.Ids
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@@ -23,6 +26,8 @@ data class RefreshTokenRotation(
/**
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
* а сессия продлевается скользяще: каждый refresh возвращает новую пару.
* Абсолютный срок (`absoluteExpiresAt`) при этом не продлевается: сессия
* живёт не дольше [absoluteTtl] с момента выдачи.
*/
@Service
class RefreshTokenService(
@@ -30,7 +35,10 @@ class RefreshTokenService(
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
@Lazy private val authService: AuthService,
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration,
@Value("\${lms.auth.absolute-session-ttl:30d}") private val absoluteTtl: Duration,
@Value("\${lms.auth.refresh-token-retention:30d}") private val retention: Duration,
) {
private val log = LoggerFactory.getLogger(RefreshTokenService::class.java)
private val random = SecureRandom()
fun issue(accountId: UUID): String {
@@ -48,13 +56,16 @@ class RefreshTokenService(
fun rotate(raw: String): RefreshTokenRotation {
val token = activeTokenOrThrow(raw)
val now = Instant.now()
if (!token.absoluteExpiresAt.isAfter(now)) {
throw UnauthorizedException(SESSION_EXPIRED)
}
authService.ensureSessionAllowed(token.accountId)
val newId = Ids.v7()
val newRaw = generateRaw()
token.revokedAt = now
token.replacedBy = newId
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId)))
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId, token.absoluteExpiresAt)))
return RefreshTokenRotation(token.accountId, newRaw)
}
@@ -95,11 +106,32 @@ class RefreshTokenService(
tokens.saveAll(active)
}
/**
* Удаляет токены, истёкшие раньше `now - retention`. Отозванные, но ещё
* не истёкшие токены сохраняются: по ним детектируется reuse.
*/
@Transactional
fun cleanupExpired(now: Instant = Instant.now()): Int {
val cutoff = now.minus(retention)
val deleted = tokens.deleteExpiredBefore(cutoff)
if (deleted > 0) {
log.info("Удалено истёкших refresh-токенов: {}", deleted)
}
return deleted
}
@Scheduled(cron = "\${lms.auth.refresh-token-cleanup-cron:0 30 3 * * *}")
@Transactional
fun cleanupExpiredTokens() {
cleanupExpired()
}
private fun newToken(
accountId: UUID,
raw: String,
now: Instant,
id: UUID = Ids.v7(),
absoluteExpiresAt: Instant = now.plus(absoluteTtl),
): RefreshToken =
RefreshToken(
id = id,
@@ -107,6 +139,7 @@ class RefreshTokenService(
tokenHash = hash(raw),
issuedAt = now,
expiresAt = now.plus(ttl),
absoluteExpiresAt = absoluteExpiresAt,
)
private fun generateRaw(): String {

View File

@@ -45,3 +45,8 @@ lms:
auth:
# Скользящая сессия: каждый refresh продлевает её на этот срок.
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d}
# Абсолютный потолок сессии: ротация не может продлить её дальше этого срока.
absolute-session-ttl: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d}
# Сколько хранить истёкшие refresh-токены перед очисткой.
refresh-token-retention: ${LMS_REFRESH_TOKEN_RETENTION:30d}
refresh-token-cleanup-cron: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:0 30 3 * * *}

View File

@@ -0,0 +1,7 @@
-- Абсолютный срок жизни сессии: ротация refresh-токена продлевает скользящее
-- окно, но не может растянуть сессию дальше absolute_expires_at.
-- Бэкфилл существующих строк использует дефолтные 30 дней; если на стенде
-- LMS_SESSION_ABSOLUTE_TIMEOUT меньше, старые сессии доживают один раз до 30 дней.
ALTER TABLE identity.refresh_tokens ADD COLUMN absolute_expires_at timestamptz;
UPDATE identity.refresh_tokens SET absolute_expires_at = issued_at + interval '30 days' WHERE absolute_expires_at IS NULL;
ALTER TABLE identity.refresh_tokens ALTER COLUMN absolute_expires_at SET NOT NULL;

View File

@@ -0,0 +1,4 @@
-- Индексы под keyset-пагинацию: сортировка (occurred_at DESC, id DESC) и та же
-- сортировка с фильтром по action.
CREATE INDEX audit_events_occurred_id_idx ON identity.audit_events (occurred_at DESC, id DESC);
CREATE INDEX audit_events_action_occurred_id_idx ON identity.audit_events (action, occurred_at DESC, id DESC);

View File

@@ -25,6 +25,8 @@ import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.ChangePasswordRequest
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.RefreshTokenRequest
import ru.ysu.lms.identity.auth.refresh.RefreshToken
import ru.ysu.lms.identity.auth.refresh.RefreshTokenRepository
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
@@ -32,6 +34,7 @@ import ru.ysu.lms.identity.roster.Credential
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.identity.roster.StudentLifecycleService
import java.time.Duration
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
@@ -62,6 +65,8 @@ class RefreshTokenIntegrationTest {
@Autowired lateinit var refreshTokens: RefreshTokenService
@Autowired lateinit var refreshTokenRepository: RefreshTokenRepository
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var lifecycle: StudentLifecycleService
@@ -188,6 +193,93 @@ class RefreshTokenIntegrationTest {
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `refresh past the absolute session expiry is rejected with unauthorized`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
val token = activeToken(UUID.fromString(student.id))
token.absoluteExpiresAt = Instant.now().minusSeconds(1)
refreshTokenRepository.save(token)
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(refreshToken))
}.andExpect {
status { isUnauthorized() }
}
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `rotation keeps the absolute session expiry unchanged`() {
val student = createStudent()
val accountId = UUID.fromString(student.id)
val original = login(student).refreshToken!!
val absoluteExpiresAt = activeToken(accountId).absoluteExpiresAt
assertThat(absoluteExpiresAt).isAfter(Instant.now().plus(Duration.ofDays(29)))
val rotated = refreshTokens.rotate(original)
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
val rotatedAgain = refreshTokens.rotate(rotated.refreshToken)
assertThat(rotatedAgain.accountId).isEqualTo(accountId)
assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt)
}
@Test
fun `cleanup deletes only tokens expired beyond retention`() {
val student = createStudent()
val accountId = UUID.fromString(student.id)
val now = Instant.parse("2026-01-31T00:00:00Z")
val expired =
token(
accountId,
"a".repeat(64),
now.minus(Duration.ofDays(40)),
now.minus(Duration.ofDays(31)),
now.minus(Duration.ofDays(10)),
)
val revokedRecently =
token(
accountId,
"b".repeat(64),
now.minus(Duration.ofDays(2)),
now.minus(Duration.ofDays(1)),
now.plus(Duration.ofDays(28)),
)
revokedRecently.revokedAt = now.minus(Duration.ofHours(2))
val absoluteDead =
token(
accountId,
"d".repeat(64),
now.minus(Duration.ofDays(45)),
now.plus(Duration.ofDays(7)),
now.minus(Duration.ofDays(40)),
)
val fresh =
token(
accountId,
"c".repeat(64),
now,
now.plus(Duration.ofDays(7)),
now.plus(Duration.ofDays(30)),
)
refreshTokenRepository.saveAll(listOf(expired, revokedRecently, absoluteDead, fresh))
val deleted = refreshTokens.cleanupExpired(now)
assertThat(deleted).isEqualTo(2)
assertThat(refreshTokenRepository.findById(expired.id)).isEmpty
assertThat(refreshTokenRepository.findById(absoluteDead.id)).isEmpty
assertThat(refreshTokenRepository.findById(revokedRecently.id)).isPresent
assertThat(refreshTokenRepository.findById(fresh.id)).isPresent
}
@Test
fun `logout revokes the refresh token idempotently`() {
val student = createStudent()
@@ -252,6 +344,23 @@ class RefreshTokenIntegrationTest {
.hasMessage("Группа закрыта")
}
private fun activeToken(accountId: UUID): RefreshToken = refreshTokenRepository.findAllByAccountIdAndRevokedAtIsNull(accountId).single()
private fun token(
accountId: UUID,
tokenHash: String,
issuedAt: Instant,
expiresAt: Instant,
absoluteExpiresAt: Instant,
): RefreshToken =
RefreshToken(
accountId = accountId,
tokenHash = tokenHash,
issuedAt = issuedAt,
expiresAt = expiresAt,
absoluteExpiresAt = absoluteExpiresAt,
)
private fun createStudent(): StudentFixture {
val number = sequence.incrementAndGet()
val group =

View File

@@ -216,6 +216,65 @@ class AuditIntegrationTest {
assertTrue((first + second).intersect(third.toSet()).isEmpty())
}
@Test
fun `cursor pagination walks all events newest first`() {
repeat(5) { index ->
write(AuditEventRequest(action = "AUDIT_CURSOR", entityType = "STUDENT", details = "cursor-${index + 1}"))
}
val first = auditPage(limit = 2, page = 0)
assertEquals(listOf("cursor-5", "cursor-4"), first.details)
assertTrue(first.cursor != null)
val second = auditPage(limit = 2, cursor = first.cursor)
assertEquals(listOf("cursor-3", "cursor-2"), second.details)
assertTrue(first.details.intersect(second.details.toSet()).isEmpty())
val third = auditPage(limit = 2, cursor = second.cursor)
assertEquals(listOf("cursor-1"), third.details)
assertTrue((first.details + second.details).intersect(third.details.toSet()).isEmpty())
val all = first.details + second.details + third.details
assertEquals(5, all.distinct().size)
}
@Test
fun `cursor pagination respects the action filter`() {
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a1"))
write(AuditEventRequest(action = "AUDIT_CURSOR_OTHER", entityType = "STUDENT", details = "x"))
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a2"))
write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a3"))
val first = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER")
assertEquals(listOf("a3", "a2"), first.details)
val second = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER", cursor = first.cursor)
assertEquals(listOf("a1"), second.details)
}
@Test
fun `malformed audit cursor is rejected with bad request`() {
mockMvc
.get("/api/v1/audit") {
with(admin())
param("cursor", "%%%not-base64%%%")
}.andExpect {
status { isBadRequest() }
jsonPath("$.detail") { value("Некорректный курсор аудита") }
}
}
@Test
fun `cursor from the newest event returns an empty page`() {
write(AuditEventRequest(action = "AUDIT_CURSOR_END", entityType = "STUDENT", details = "only"))
val first = auditPage(limit = 1, action = "AUDIT_CURSOR_END")
assertEquals(listOf("only"), first.details)
val next = auditPage(limit = 5, action = "AUDIT_CURSOR_END", cursor = first.cursor)
assertEquals(emptyList<String>(), next.details)
}
@Test
fun `negative page is clamped to the first page and page past the end is empty`() {
repeat(3) { index ->
@@ -319,6 +378,42 @@ class AuditIntegrationTest {
assertTrue(auditEvents.findById(deleteEvent.id).isPresent)
}
private data class AuditPage(
val details: List<String>,
val cursor: String?,
)
private fun auditPage(
limit: Int,
page: Int? = null,
cursor: String? = null,
action: String? = null,
): AuditPage {
val body =
mockMvc
.get("/api/v1/audit") {
with(admin())
param("limit", limit.toString())
page?.let { param("page", it.toString()) }
cursor?.let { param("cursor", it) }
action?.let { param("action", it) }
}.andExpect {
status { isOk() }
}.andReturn()
.response
.getContentAsString(StandardCharsets.UTF_8)
val nodes = objectMapper.readTree(body)
return AuditPage(
details = nodes.map { it.path("details").asText() },
cursor =
nodes
.lastOrNull()
?.path("cursor")
?.asText()
?.takeIf { it.isNotEmpty() },
)
}
private fun detailsOfPage(
limit: Int,
page: Int,