feat(auth): короткий access, refresh и 7-дневная sliding-сессия

This commit is contained in:
2026-10-28 13:26:22 +03:00
parent 08e5a62c0e
commit 3adfba9bec
29 changed files with 1261 additions and 14 deletions

View File

@@ -20,3 +20,11 @@ LMS_BACKUP_KEEP_DAYS=14
# Внутренний токен между identity и progress (профиль студента). # Внутренний токен между identity и progress (профиль студента).
# Обязателен в production, задайте длинную случайную строку. # Обязателен в production, задайте длинную случайную строку.
LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string
# Сроки аутентификации и сессии (значения по умолчанию подходят).
# Access JWT: короткий, обновляется refresh-токеном.
LMS_ACCESS_TOKEN_TTL=15m
# Refresh-токен: 7 дней, ротация и отзыв при выходе/смене пароля.
LMS_REFRESH_TOKEN_TTL=7d
# Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически.
LMS_SESSION_TIMEOUT=7d

View File

@@ -10,6 +10,7 @@ import lms.evidence.EvidenceCheckTask
val evidenceTestTasks = val evidenceTestTasks =
listOf( listOf(
":libs:markdown:test", ":libs:markdown:test",
":services:gateway:test",
":services:identity-service:test", ":services:identity-service:test",
":services:academic-service:test", ":services:academic-service:test",
":services:progress-service:test", ":services:progress-service:test",

View File

@@ -45,6 +45,8 @@ services:
environment: environment:
<<: [*db-env, *internal-env] <<: [*db-env, *internal-env]
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m}
LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d}
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
networks: [lms] networks: [lms]
@@ -105,6 +107,7 @@ services:
environment: environment:
LMS_GATEWAY_URL: http://gateway:8080 LMS_GATEWAY_URL: http://gateway:8080
SERVER_PORT: 8090 SERVER_PORT: 8090
LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d}
ports: ports:
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
depends_on: depends_on:

View File

@@ -66,6 +66,8 @@ class JwtAuthenticationFilter(
path.startsWith("/actuator") || path.startsWith("/actuator") ||
path == "/api/v1/auth/login" || path == "/api/v1/auth/login" ||
path == "/api/v1/auth/setup" || path == "/api/v1/auth/setup" ||
path == "/api/v1/auth/refresh" ||
path == "/api/v1/auth/logout" ||
path == "/api/v1/auth/health" || path == "/api/v1/auth/health" ||
path == "/api/v1/auth/jwks.json" || path == "/api/v1/auth/jwks.json" ||
path.startsWith("/api/v1/content/") path.startsWith("/api/v1/content/")

View File

@@ -0,0 +1,42 @@
package ru.ysu.lms.gateway.security
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.http.HttpStatus
import org.springframework.mock.http.server.reactive.MockServerHttpRequest
import org.springframework.mock.web.server.MockServerWebExchange
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
class JwtAuthenticationFilterTest {
private val filter = JwtAuthenticationFilter(mockk<JwtDecoder>(relaxed = true))
@Test
fun `refresh and logout are public`() {
listOf("/api/v1/auth/refresh", "/api/v1/auth/logout").forEach { path ->
var called = false
val exchange = MockServerWebExchange.from(MockServerHttpRequest.post(path).build())
filter.filter(exchange, chain { called = true }).block()
assertTrue(called, "gateway did not pass $path")
}
}
@Test
fun `protected route without bearer token is rejected`() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/courses").build())
filter.filter(exchange, chain { }).block()
assertTrue(
exchange.response.statusCode == HttpStatus.UNAUTHORIZED,
"expected 401, got ${exchange.response.statusCode}",
)
}
private fun chain(onCall: () -> Unit): GatewayFilterChain =
GatewayFilterChain { _: ServerWebExchange ->
onCall()
Mono.empty()
}
}

View File

@@ -3,19 +3,23 @@ package ru.ysu.lms.identity.auth
import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.RSAKey
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1/auth") @RequestMapping("/api/v1/auth")
class AuthController( class AuthController(
private val authService: AuthService, private val authService: AuthService,
private val refreshTokens: RefreshTokenService,
private val rsaKey: RSAKey, private val rsaKey: RSAKey,
) { ) {
@GetMapping("/health") @GetMapping("/health")
@@ -36,6 +40,19 @@ class AuthController(
@Valid @RequestBody body: LoginRequest, @Valid @RequestBody body: LoginRequest,
): AuthResponse = authService.login(body) ): AuthResponse = authService.login(body)
@PostMapping("/refresh")
fun refresh(
@Valid @RequestBody body: RefreshTokenRequest,
): AuthResponse = authService.refresh(body.refreshToken)
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun logout(
@RequestBody(required = false) body: RefreshTokenRequest?,
) {
body?.refreshToken?.let(refreshTokens::revoke)
}
@GetMapping("/me") @GetMapping("/me")
fun me( fun me(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,

View File

@@ -27,6 +27,11 @@ data class ChangePasswordRequest(
val new2: String, val new2: String,
) )
data class RefreshTokenRequest(
@field:NotBlank(message = "Укажите refresh-токен")
val refreshToken: String,
)
data class AuthResponse( data class AuthResponse(
val token: String, val token: String,
val id: String, val id: String,
@@ -34,6 +39,8 @@ data class AuthResponse(
val role: String, val role: String,
val name: String, val name: String,
val mustChangePassword: Boolean, val mustChangePassword: Boolean,
val refreshToken: String? = null,
val accessTokenExpiresIn: Long = 0,
) )
data class MeResponse( data class MeResponse(

View File

@@ -12,6 +12,7 @@ import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.GroupStatus import ru.ysu.lms.identity.domain.GroupStatus
@@ -34,6 +35,7 @@ class AuthService(
private val jwtEncoder: JwtEncoder, private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties, private val jwtProperties: JwtProperties,
private val backoff: LoginBackoff, private val backoff: LoginBackoff,
private val refreshTokens: RefreshTokenService,
) { ) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN) fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@@ -72,6 +74,26 @@ class AuthService(
return toResponse(account, displayName(account)) return toResponse(account, displayName(account))
} }
/** Продлевает сессию: обменивает refresh-токен на новую пару access/refresh. */
fun refresh(rawToken: String): AuthResponse {
val rotation = refreshTokens.rotate(rawToken)
val account =
accounts
.findById(rotation.accountId)
.orElseThrow { UnauthorizedException(RefreshTokenService.SESSION_EXPIRED) }
return toResponse(account, displayName(account), rotation.refreshToken)
}
/** Проверяет, что текущее состояние аккаунта/студента/группы всё ещё разрешает сессию. */
@Transactional(readOnly = true)
fun ensureSessionAllowed(accountId: UUID) {
val account =
accounts
.findById(accountId)
.orElseThrow { UnauthorizedException(RefreshTokenService.SESSION_EXPIRED) }
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
private fun ensureLoginAllowed(account: Account) { private fun ensureLoginAllowed(account: Account) {
loginDeniedReason(account)?.let { throw UnauthorizedException(it) } loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
} }
@@ -128,6 +150,7 @@ class AuthService(
account.tempPassword = null account.tempPassword = null
account.updatedAt = Instant.now() account.updatedAt = Instant.now()
accounts.save(account) accounts.save(account)
refreshTokens.revokeAll(accountId)
} }
private fun validatePassword( private fun validatePassword(
@@ -155,6 +178,7 @@ class AuthService(
private fun toResponse( private fun toResponse(
account: Account, account: Account,
name: String, name: String,
refreshToken: String = refreshTokens.issue(account.id),
): AuthResponse = ): AuthResponse =
AuthResponse( AuthResponse(
token = issueToken(account, name), token = issueToken(account, name),
@@ -163,6 +187,8 @@ class AuthService(
role = account.role.name, role = account.role.name,
name = name, name = name,
mustChangePassword = account.mustChangePassword, mustChangePassword = account.mustChangePassword,
refreshToken = refreshToken,
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
) )
private fun issueToken( private fun issueToken(

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.identity.auth.refresh
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/**
* Refresh-токен сессии. В базе лежит только SHA-256-хеш raw-токена.
* [replacedBy] задаёт следующее звено цепочки ротаций: повторное использование
* уже заменённого токена считается компрометацией.
*/
@Entity
@Table(name = "refresh_tokens")
class RefreshToken(
@Id
var id: UUID = Ids.v7(),
@Column(name = "account_id", nullable = false)
var accountId: UUID = Ids.v7(),
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
var tokenHash: String = "",
@Column(name = "issued_at", nullable = false)
var issuedAt: Instant = Instant.now(),
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant = Instant.now(),
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by")
var replacedBy: UUID? = null,
)

View File

@@ -0,0 +1,10 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
fun findByTokenHash(tokenHash: String): RefreshToken?
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<RefreshToken>
}

View File

@@ -0,0 +1,127 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import ru.ysu.lms.common.Ids
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.HexFormat
import java.util.UUID
/** Результат ротации: аккаунт сессии и новый raw refresh-токен. */
data class RefreshTokenRotation(
val accountId: UUID,
val refreshToken: String,
)
/**
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
* а сессия продлевается скользяще: каждый refresh возвращает новую пару.
*/
@Service
class RefreshTokenService(
private val tokens: RefreshTokenRepository,
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
@Lazy private val authService: AuthService,
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration,
) {
private val random = SecureRandom()
fun issue(accountId: UUID): String {
val now = Instant.now()
val raw = generateRaw()
tokens.save(newToken(accountId, raw, now))
return raw
}
/**
* Обменивает raw refresh-токен на новый. Старый токен помечается заменённым.
* Повторное использование заменённого токена трактуется как кража: все
* активные сессии аккаунта отзываются.
*/
fun rotate(raw: String): RefreshTokenRotation {
val token = activeTokenOrThrow(raw)
val now = Instant.now()
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)))
return RefreshTokenRotation(token.accountId, newRaw)
}
private fun activeTokenOrThrow(raw: String): RefreshToken {
val token =
tokens.findByTokenHash(hash(raw))
?: throw UnauthorizedException(SESSION_EXPIRED)
if (token.revokedAt != null) rejectRevoked(token)
if (!token.expiresAt.isAfter(Instant.now())) {
throw UnauthorizedException(SESSION_EXPIRED)
}
return token
}
private fun rejectRevoked(token: RefreshToken): Nothing {
if (token.replacedBy != null) {
revokeAll(token.accountId)
throw UnauthorizedException(SESSION_REUSED)
}
throw UnauthorizedException(SESSION_EXPIRED)
}
/** Идемпотентный logout: неизвестный или уже отозванный токен игнорируется. */
fun revoke(raw: String) {
val token = tokens.findByTokenHash(hash(raw)) ?: return
if (token.revokedAt == null) {
token.revokedAt = Instant.now()
tokens.save(token)
}
}
/** Отзывает все активные refresh-токены аккаунта (смена пароля, reuse-атака). */
fun revokeAll(accountId: UUID) {
val now = Instant.now()
val active = tokens.findAllByAccountIdAndRevokedAtIsNull(accountId)
if (active.isEmpty()) return
active.forEach { it.revokedAt = now }
tokens.saveAll(active)
}
private fun newToken(
accountId: UUID,
raw: String,
now: Instant,
id: UUID = Ids.v7(),
): RefreshToken =
RefreshToken(
id = id,
accountId = accountId,
tokenHash = hash(raw),
issuedAt = now,
expiresAt = now.plus(ttl),
)
private fun generateRaw(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun hash(raw: String): String =
HexFormat
.of()
.formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray(Charsets.UTF_8)))
companion object {
const val SESSION_EXPIRED = "Сессия истекла, войдите заново"
const val SESSION_REUSED = "Сессия уже использована, войдите заново"
}
}

View File

@@ -30,6 +30,8 @@ class SecurityConfig {
"/api/v1/auth/health", "/api/v1/auth/health",
"/api/v1/auth/setup", "/api/v1/auth/setup",
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout",
"/api/v1/auth/jwks.json", "/api/v1/auth/jwks.json",
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере. // Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
"/api/v1/internal/**", "/api/v1/internal/**",

View File

@@ -40,5 +40,8 @@ lms:
internal: internal:
token: ${LMS_INTERNAL_TOKEN:dev-internal-token} token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
jwt: jwt:
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml). # Короткоживущий access-токен; сессия продлевается refresh-токеном.
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d} access-token-ttl: ${LMS_ACCESS_TOKEN_TTL:15m}
auth:
# Скользящая сессия: каждый refresh продлевает её на этот срок.
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d}

View File

@@ -0,0 +1,13 @@
-- Refresh-токены сессий. Хранится только SHA-256-хеш raw-токена; цепочка
-- замен (replaced_by) позволяет обнаружить повторное использование старого токена.
CREATE TABLE identity.refresh_tokens (
id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES identity.accounts(id) ON DELETE CASCADE,
token_hash varchar(64) NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
replaced_by uuid
);
CREATE INDEX refresh_tokens_account_idx ON identity.refresh_tokens (account_id);
CREATE INDEX refresh_tokens_expires_idx ON identity.refresh_tokens (expires_at);

View File

@@ -17,6 +17,7 @@ import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginBackoff import ru.ysu.lms.identity.auth.LoginBackoff
import ru.ysu.lms.identity.auth.LoginRequest import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.LoginAttempt import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.AccountRepository import ru.ysu.lms.identity.repo.AccountRepository
@@ -64,6 +65,7 @@ class AuthServiceLoginBackoffTest {
jwtEncoder, jwtEncoder,
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
backoff, backoff,
mockk<RefreshTokenService>(relaxed = true),
) )
} }

View File

@@ -8,6 +8,7 @@ import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.JwtEncoder import org.springframework.security.oauth2.jwt.JwtEncoder
import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginBackoff import ru.ysu.lms.identity.auth.LoginBackoff
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment import ru.ysu.lms.identity.domain.Enrollment
@@ -90,6 +91,7 @@ class AuthServiceMeActiveTest {
mockk<JwtEncoder>(), mockk<JwtEncoder>(),
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
LoginBackoff(mockk<LoginAttemptRepository>(relaxed = true)), LoginBackoff(mockk<LoginAttemptRepository>(relaxed = true)),
mockk<RefreshTokenService>(relaxed = true),
) )
} }
} }

View File

@@ -0,0 +1,294 @@
package ru.ysu.lms.identity
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthResponse
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.RefreshTokenService
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
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.util.UUID
import java.util.concurrent.atomic.AtomicInteger
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
class RefreshTokenIntegrationTest {
companion object {
private val sequence = AtomicInteger(9200)
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var authService: AuthService
@Autowired lateinit var refreshTokens: RefreshTokenService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var lifecycle: StudentLifecycleService
@Autowired lateinit var jwtDecoder: JwtDecoder
@Autowired lateinit var context: WebApplicationContext
@Autowired lateinit var jsonMapper: tools.jackson.databind.json.JsonMapper
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `login returns short lived access token and refresh token`() {
val student = createStudent()
val response = login(student)
assertThat(response.token).isNotBlank()
assertThat(response.refreshToken).isNotBlank()
assertThat(response.accessTokenExpiresIn).isGreaterThan(0).isLessThanOrEqualTo(3600)
val jwt = jwtDecoder.decode(response.token)
val lifetime = Duration.between(jwt.issuedAt, jwt.expiresAt)
assertThat(lifetime).isLessThan(Duration.ofDays(1))
assertThat(lifetime).isLessThanOrEqualTo(Duration.ofMinutes(15))
}
@Test
fun `refresh endpoint rotates token and logout endpoint revokes it`() {
val student = createStudent()
val loginResult =
mockMvc
.post("/api/v1/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(LoginRequest(student.studentNo, student.password))
}.andExpect {
status { isOk() }
jsonPath("$.refreshToken") { isNotEmpty() }
jsonPath("$.accessTokenExpiresIn") { value(900) }
}.andReturn()
val original = readAuth(loginResult)
val refreshResult =
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(original.refreshToken!!))
}.andExpect {
status { isOk() }
jsonPath("$.refreshToken") { isNotEmpty() }
}.andReturn()
val rotated = readAuth(refreshResult)
assertThat(rotated.refreshToken).isNotEqualTo(original.refreshToken)
assertThat(rotated.token).isNotBlank()
mockMvc
.post("/api/v1/auth/logout") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(rotated.refreshToken!!))
}.andExpect {
status { isNoContent() }
}
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(rotated.refreshToken!!))
}.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `logout without body is accepted`() {
mockMvc
.post("/api/v1/auth/logout")
.andExpect {
status { isNoContent() }
}
}
@Test
fun `refresh rotates the token and old token is rejected`() {
val student = createStudent()
val original = login(student).refreshToken!!
val rotated = refreshTokens.rotate(original)
assertThat(rotated.accountId).isEqualTo(UUID.fromString(student.id))
assertThat(rotated.refreshToken).isNotEqualTo(original)
val twice = refreshTokens.rotate(rotated.refreshToken)
assertThat(twice.refreshToken).isNotEqualTo(rotated.refreshToken)
assertThatThrownBy { refreshTokens.rotate(original) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_REUSED)
}
@Test
fun `reuse of a rotated token revokes every active session`() {
val student = createStudent()
val original = login(student).refreshToken!!
val rotated = refreshTokens.rotate(original)
assertThatThrownBy { refreshTokens.rotate(original) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_REUSED)
assertThatThrownBy { refreshTokens.rotate(rotated.refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `unknown refresh token is rejected`() {
assertThatThrownBy { refreshTokens.rotate("not-a-real-token") }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `logout revokes the refresh token idempotently`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
refreshTokens.revoke(refreshToken)
refreshTokens.revoke(refreshToken)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `password change revokes refresh tokens`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
authService.changePassword(
UUID.fromString(student.id),
ChangePasswordRequest(old = student.password, new = "newPassword123", new2 = "newPassword123"),
)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `expelled student cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
lifecycle.expel(UUID.fromString(student.id), null, null)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Доступ к системе закрыт")
}
@Test
fun `archived account cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
lifecycle.archive(UUID.fromString(student.id), null)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Аккаунт архивирован")
}
@Test
fun `student in archived group cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
groupService.archiveGroup(student.groupId)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Группа закрыта")
}
private fun createStudent(): StudentFixture {
val number = sequence.incrementAndGet()
val group =
groupService.create(
CreateGroupRequest(
stream = "РТ",
course = 1,
number = number.toShort(),
degree = "БО",
enrollmentYear = 2026,
),
)
val groupId = UUID.fromString(group.id)
val credential =
groupService.createStudent(
groupId,
CreateStudentRequest(
studentNo = number.toString(),
lastName = "Тестов",
firstName = "Тест",
),
null,
)
return StudentFixture(credential, groupId)
}
private fun login(student: StudentFixture): AuthResponse =
authService.login(LoginRequest(student.credential.studentNo, student.credential.password))
private fun readAuth(result: MvcResult): AuthResponse = jsonMapper.readValue(result.response.contentAsString, AuthResponse::class.java)
private data class StudentFixture(
val credential: Credential,
val groupId: UUID,
) {
val id: String get() = credential.id
val studentNo: String get() = credential.studentNo
val password: String get() = credential.password
}
}

View File

@@ -58,3 +58,31 @@ fun ApiClient.changePassword(
.toBodilessEntity() .toBodilessEntity()
} }
} }
/** Обменивает refresh-токен на новую пару access/refresh. */
fun ApiClient.refresh(refreshToken: String): AuthResponseDto =
call {
client
.post()
.uri("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.body(RefreshTokenPayload(refreshToken))
.retrieve()
.body(AuthResponseDto::class.java)
}
/**
* Отзывает refresh-токен. Вызывающие стороны используют runCatching:
* недоступность identity не должна мешать выходу из BFF-сессии.
*/
fun ApiClient.logout(refreshToken: String) {
call {
client
.post()
.uri("/api/v1/auth/logout")
.contentType(MediaType.APPLICATION_JSON)
.body(LogoutPayload(refreshToken))
.retrieve()
.toBodilessEntity()
}
}

View File

@@ -11,6 +11,9 @@ data class HealthDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class AuthResponseDto( data class AuthResponseDto(
val token: String = "", val token: String = "",
val refreshToken: String? = null,
/** Время жизни access-токена в секундах. */
val accessTokenExpiresIn: Long = 0,
val id: String = "", val id: String = "",
val login: String = "", val login: String = "",
val role: String = "", val role: String = "",
@@ -45,6 +48,14 @@ data class ChangePasswordPayload(
val new2: String, val new2: String,
) )
data class RefreshTokenPayload(
val refreshToken: String,
)
data class LogoutPayload(
val refreshToken: String,
)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDto( data class GroupDto(
val id: String = "", val id: String = "",

View File

@@ -13,6 +13,7 @@ import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.adminExists import ru.ysu.lms.web.api.adminExists
import ru.ysu.lms.web.api.changePassword import ru.ysu.lms.web.api.changePassword
import ru.ysu.lms.web.api.login import ru.ysu.lms.web.api.login
import ru.ysu.lms.web.api.logout
import ru.ysu.lms.web.api.setup import ru.ysu.lms.web.api.setup
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser import ru.ysu.lms.web.session.SessionUser
@@ -99,6 +100,9 @@ class AuthPageController(
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login" val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
return try { return try {
api.changePassword(token, old, new, new2) api.changePassword(token, old, new, new2)
// identity-service отзывает все refresh-токены при смене пароля. Access-токен
// доживает до истечения, но обновить его без повторного входа уже нельзя.
session.removeAttribute(SessionKeys.REFRESH_TOKEN)
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false)) session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
"redirect:" + homeFor(user) "redirect:" + homeFor(user)
} catch (ex: ApiException) { } catch (ex: ApiException) {
@@ -109,6 +113,11 @@ class AuthPageController(
@PostMapping("/logout") @PostMapping("/logout")
fun logout(session: HttpSession): String { fun logout(session: HttpSession): String {
val refreshToken = session.getAttribute(SessionKeys.REFRESH_TOKEN) as? String
if (refreshToken != null) {
// Best-effort: даже если identity недоступна, локальную сессию завершаем.
runCatching { api.logout(refreshToken) }
}
session.invalidate() session.invalidate()
return "redirect:/login" return "redirect:/login"
} }
@@ -123,6 +132,11 @@ class AuthPageController(
request.getSession(false)?.invalidate() request.getSession(false)?.invalidate()
val session = request.getSession(true) val session = request.getSession(true)
session.setAttribute(SessionKeys.TOKEN, auth.token) session.setAttribute(SessionKeys.TOKEN, auth.token)
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
session.setAttribute(
SessionKeys.TOKEN_EXPIRES_AT,
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000,
)
session.setAttribute( session.setAttribute(
SessionKeys.USER, SessionKeys.USER,
SessionUser( SessionUser(

View File

@@ -10,13 +10,15 @@ import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.MeDto import ru.ysu.lms.web.api.MeDto
/** /**
* Проверяет вход и периодически перепроверяет аккаунт в identity-service. * Проверяет вход, поддерживает access-токен живым и периодически перепроверяет
* Сессия BFF живёт дольше access-токена, поэтому без такой проверки отчисленный * аккаунт в identity-service. Сессия BFF живёт дольше access-токена, поэтому без
* студент или архивная группа продолжали бы пользоваться порталом. * обновления и такой проверки отчисленный студент или архивная группа продолжали бы
* пользоваться порталом.
*/ */
@Component @Component
class AuthInterceptor( class AuthInterceptor(
private val api: ApiClient, private val api: ApiClient,
private val tokenService: SessionTokenService,
) : HandlerInterceptor { ) : HandlerInterceptor {
override fun preHandle( override fun preHandle(
request: HttpServletRequest, request: HttpServletRequest,
@@ -29,16 +31,36 @@ class AuthInterceptor(
response.sendRedirect("/login") response.sendRedirect("/login")
return false return false
} }
if (!revalidate(session, user, response)) return false if (!ensureFreshToken(session, user, response)) return false
val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
if (!revalidate(session, refreshedUser, response)) return false
return !redirectIfPasswordMustChange(session, refreshedUser, request, response)
}
/** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */
private fun redirectIfPasswordMustChange(
session: HttpSession,
user: SessionUser,
request: HttpServletRequest,
response: HttpServletResponse,
): Boolean {
val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
val uri = request.requestURI if (!current.mustChangePassword || request.requestURI.startsWith("/change-password")) return false
if (current.mustChangePassword && !uri.startsWith("/change-password")) { response.sendRedirect("/change-password")
response.sendRedirect("/change-password")
return false
}
return true return true
} }
/** true — токен актуален; false — refresh невозможен, сессия завершена и отправлен redirect. */
private fun ensureFreshToken(
session: HttpSession,
user: SessionUser,
response: HttpServletResponse,
): Boolean {
if (tokenService.refreshIfNeeded(session, user) != RefreshOutcome.EXPIRED) return true
expire(session, response)
return false
}
/** true — запрос можно продолжать; false — сессия уже завершена и отправлен redirect. */ /** true — запрос можно продолжать; false — сессия уже завершена и отправлен redirect. */
private fun revalidate( private fun revalidate(
session: HttpSession, session: HttpSession,

View File

@@ -0,0 +1,43 @@
package ru.ysu.lms.web.session
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.time.Duration
/**
* Sliding-сессия: серверный timeout продлевается при активности, но cookie
* с абсолютным Max-Age сама по себе не продлевается. Перевыпускаем cookie на
* каждом ответе, чтобы через 7 дней активной работы не требовать пароль.
*/
@Component
class SessionCookieRefreshFilter(
@Value("\${server.servlet.session.cookie.name:LMS_SESSION}") private val cookieName: String,
@Value("\${server.servlet.session.timeout:7d}") private val sessionTtl: Duration,
@Value("\${server.servlet.session.cookie.secure:true}") private val secureCookie: Boolean,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
filterChain.doFilter(request, response)
val session = request.getSession(false) ?: return
if (response.isCommitted) return
val cookie =
ResponseCookie
.from(cookieName, session.id)
.httpOnly(true)
.secure(secureCookie || request.isSecure)
.path(request.contextPath.ifBlank { "/" })
.sameSite("Lax")
.maxAge(sessionTtl)
.build()
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
}
}

View File

@@ -0,0 +1,68 @@
package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Component
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.refresh
/** Результат проверки access-токена BFF-сессии. */
enum class RefreshOutcome {
/** Токен актуален (или успешно обновлён). */
OK,
/** Обновить токен нельзя: требуется повторный вход. */
EXPIRED,
/** Identity недоступна — не разлогиниваем пользователей из-за сбоя. */
FAILED_OPEN,
}
/**
* Поддерживает access-токен BFF-сессии живым: обновляет его по refresh-токену
* незадолго до истечения. Сессия при этом живёт 7 дней (sliding), а access-токен — 15 минут.
*/
@Component
class SessionTokenService(
private val api: ApiClient,
) {
/**
* @param user текущий пользователь сессии: после refresh роль и флаг смены пароля
* могли измениться на стороне identity-service.
*/
fun refreshIfNeeded(
session: HttpSession,
user: SessionUser,
): RefreshOutcome {
val expiresAt = session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as? Long
if (expiresAt != null && System.currentTimeMillis() < expiresAt - REFRESH_SKEW_MS) {
return RefreshOutcome.OK
}
val refreshToken =
session.getAttribute(SessionKeys.REFRESH_TOKEN) as? String
?: return RefreshOutcome.EXPIRED
return try {
val auth = api.refresh(refreshToken)
session.setAttribute(SessionKeys.TOKEN, auth.token)
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
session.setAttribute(
SessionKeys.TOKEN_EXPIRES_AT,
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000,
)
session.setAttribute(
SessionKeys.USER,
user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword),
)
RefreshOutcome.OK
} catch (ex: ApiException) {
if (ex.status == 401 || ex.status == 403) RefreshOutcome.EXPIRED else RefreshOutcome.FAILED_OPEN
} catch (_: Exception) {
RefreshOutcome.FAILED_OPEN
}
}
private companion object {
/** Обновляем заранее, чтобы запрос не ушёл с уже истёкшим access-токеном. */
const val REFRESH_SKEW_MS = 120_000L
}
}

View File

@@ -14,6 +14,8 @@ data class SessionUser(
object SessionKeys { object SessionKeys {
const val TOKEN = "lms.token" const val TOKEN = "lms.token"
const val REFRESH_TOKEN = "lms.refreshToken"
const val TOKEN_EXPIRES_AT = "lms.tokenExpiresAt"
const val USER = "lms.user" const val USER = "lms.user"
const val STATUS_CHECKED_AT = "lms.statusCheckedAt" const val STATUS_CHECKED_AT = "lms.statusCheckedAt"
} }

View File

@@ -4,14 +4,16 @@ server:
forward-headers-strategy: framework forward-headers-strategy: framework
servlet: servlet:
session: session:
timeout: 30d # BFF-сессия живёт 7 дней (sliding): вкладка может простаивать, access-токен обновляется сам.
timeout: ${LMS_SESSION_TIMEOUT:7d}
cookie: cookie:
name: LMS_SESSION
http-only: true http-only: true
same-site: lax same-site: lax
# Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры # Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры
# тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false. # тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false.
secure: ${LMS_SESSION_COOKIE_SECURE:true} secure: ${LMS_SESSION_COOKIE_SECURE:true}
max-age: 30d max-age: ${LMS_SESSION_TIMEOUT:7d}
tracking-modes: cookie tracking-modes: cookie
spring: spring:

View File

@@ -0,0 +1,161 @@
package ru.ysu.lms.web.auth
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.changePassword
import ru.ysu.lms.web.api.login
import ru.ysu.lms.web.api.logout
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
class AuthPageControllerTest {
private val api = mockk<ApiClient>()
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(AuthPageController(api))
.build()
@BeforeEach
fun setUp() {
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `login stores access and refresh tokens with expiry`() {
every { api.login("student", "secret") } returns
AuthResponseDto(
token = "access",
refreshToken = "refresh",
accessTokenExpiresIn = 900,
id = "s1",
login = "student",
role = "STUDENT",
name = "Студент",
mustChangePassword = false,
)
val before = System.currentTimeMillis()
val result =
mockMvc
.perform(post("/login").param("login", "student").param("password", "secret"))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/student"))
.andReturn()
val session = result.request.getSession(false) as MockHttpSession
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("refresh")
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).login).isEqualTo("student")
}
@Test
fun `login without refresh token still creates the session`() {
every { api.login("student", "secret") } returns
AuthResponseDto(
token = "access",
accessTokenExpiresIn = 900,
id = "s1",
login = "student",
role = "STUDENT",
name = "Студент",
)
val result =
mockMvc
.perform(post("/login").param("login", "student").param("password", "secret"))
.andExpect(status().is3xxRedirection)
.andReturn()
val session = result.request.getSession(false) as MockHttpSession
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull()
}
@Test
fun `logout revokes refresh token and invalidates session`() {
every { api.logout("refresh") } just Runs
val session = sessionFor()
mockMvc
.perform(post("/logout").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login"))
verify(exactly = 1) { api.logout("refresh") }
assertThat(session.isInvalid).isTrue()
}
@Test
fun `logout still ends the session when identity is unavailable`() {
every { api.logout("refresh") } throws ApiException(502, "Сервис недоступен")
val session = sessionFor()
mockMvc
.perform(post("/logout").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login"))
assertThat(session.isInvalid).isTrue()
}
@Test
fun `password change clears refresh token but keeps access token`() {
every { api.changePassword("access", "old", "new", "new") } just Runs
val session = sessionFor()
mockMvc
.perform(
post("/change-password")
.session(session)
.param("old", "old")
.param("new", "new")
.param("new2", "new"),
).andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/student"))
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull()
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).mustChangePassword).isFalse()
}
private fun sessionFor(): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "access")
setAttribute(SessionKeys.REFRESH_TOKEN, "refresh")
setAttribute(
SessionKeys.USER,
SessionUser(
id = "s1",
login = "student",
role = "STUDENT",
name = "Студент",
mustChangePassword = true,
),
)
}
}

View File

@@ -2,8 +2,12 @@ package ru.ysu.lms.web.session
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession import org.springframework.mock.web.MockHttpSession
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
@@ -15,7 +19,9 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.MeDto import ru.ysu.lms.web.api.MeDto
import ru.ysu.lms.web.api.refresh
class AuthInterceptorTest { class AuthInterceptorTest {
@RestController @RestController
@@ -29,9 +35,20 @@ class AuthInterceptorTest {
private val mockMvc: MockMvc = private val mockMvc: MockMvc =
MockMvcBuilders MockMvcBuilders
.standaloneSetup(TestPage()) .standaloneSetup(TestPage())
.addInterceptors(AuthInterceptor(api)) .addInterceptors(AuthInterceptor(api, SessionTokenService(api)))
.build() .build()
@BeforeEach
fun setUp() {
// refresh — extension-функция ApiClient, MockK подменяет её через файл-фасад.
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test @Test
fun `active session passes and caches check time`() { fun `active session passes and caches check time`() {
every { api.me(TOKEN) } returns me(active = true) every { api.me(TOKEN) } returns me(active = true)
@@ -132,12 +149,95 @@ class AuthInterceptorTest {
.andExpect(redirectedUrl("/login")) .andExpect(redirectedUrl("/login"))
} }
@Test
fun `access token expiring soon is refreshed and session updated`() {
every { api.refresh(REFRESH_TOKEN) } returns
AuthResponseDto(
token = "new-token",
refreshToken = "new-refresh-token",
accessTokenExpiresIn = 900,
id = "test-id",
login = "test-login",
role = "TEACHER",
name = "Тестовый пользователь",
mustChangePassword = false,
)
val session = sessionFor(expiresInMs = 30_000L)
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
val before = System.currentTimeMillis()
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-token")
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("new-refresh-token")
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).role).isEqualTo("TEACHER")
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
}
@Test
fun `fresh access token does not trigger refresh`() {
val session = sessionFor(expiresInMs = 600_000L)
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
verify(exactly = 0) { api.refresh(any()) }
}
@Test
fun `rejected refresh invalidates session and redirects to expired login`() {
every { api.refresh(REFRESH_TOKEN) } throws ApiException(401, "Refresh-токен истёк")
val session = sessionFor(expiresInMs = 30_000L)
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login?expired=1"))
assertThat(session.isInvalid).isTrue()
}
@Test
fun `missing refresh token with expired access token redirects to login`() {
val session = sessionFor(expiresInMs = 30_000L, refreshToken = null)
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login?expired=1"))
assertThat(session.isInvalid).isTrue()
}
@Test
fun `identity outage during refresh fails open and keeps session`() {
every { api.refresh(REFRESH_TOKEN) } throws ApiException(502, "Сервис недоступен")
val session = sessionFor(expiresInMs = 30_000L)
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
assertThat(session.isInvalid).isFalse()
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo(TOKEN)
}
private fun sessionFor( private fun sessionFor(
role: String = "STUDENT", role: String = "STUDENT",
mustChangePassword: Boolean = false, mustChangePassword: Boolean = false,
expiresInMs: Long = 600_000L,
refreshToken: String? = REFRESH_TOKEN,
): MockHttpSession = ): MockHttpSession =
MockHttpSession().apply { MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, TOKEN) setAttribute(SessionKeys.TOKEN, TOKEN)
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
setAttribute( setAttribute(
SessionKeys.USER, SessionKeys.USER,
SessionUser( SessionUser(
@@ -165,5 +265,6 @@ class AuthInterceptorTest {
private companion object { private companion object {
const val TOKEN = "test-token" const val TOKEN = "test-token"
const val REFRESH_TOKEN = "test-refresh-token"
} }
} }

View File

@@ -0,0 +1,36 @@
package ru.ysu.lms.web.session
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockFilterChain
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import java.time.Duration
class SessionCookieRefreshFilterTest {
private val filter = SessionCookieRefreshFilter("LMS_SESSION", Duration.ofDays(7), secureCookie = false)
@Test
fun `refreshes cookie max age on every existing session`() {
val request = MockHttpServletRequest()
request.getSession(true)
val response = MockHttpServletResponse()
filter.doFilter(request, response, MockFilterChain())
val cookie = response.getHeader("Set-Cookie")
assertThat(cookie).contains("LMS_SESSION=")
assertThat(cookie).contains("Max-Age=604800")
assertThat(cookie).contains("HttpOnly")
assertThat(cookie).contains("SameSite=Lax")
}
@Test
fun `does not create cookie without session`() {
val response = MockHttpServletResponse()
filter.doFilter(MockHttpServletRequest(), response, MockFilterChain())
assertThat(response.getHeader("Set-Cookie")).isNull()
}
}

View File

@@ -0,0 +1,167 @@
package ru.ysu.lms.web.session
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpSession
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.api.refresh
class SessionTokenServiceTest {
private val api = mockk<ApiClient>()
private val service = SessionTokenService(api)
@BeforeEach
fun setUp() {
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
}
@AfterEach
fun tearDown() {
unmockkAll()
}
@Test
fun `fresh access token needs no refresh`() {
val session = session(expiresInMs = 600_000L)
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
verify(exactly = 0) { api.refresh(any()) }
}
@Test
fun `missing expiry and no refresh token means expired`() {
val session = session(expiresInMs = null, refreshToken = null)
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.EXPIRED)
verify(exactly = 0) { api.refresh(any()) }
}
@Test
fun `token expiring soon is refreshed and session is updated`() {
every { api.refresh(REFRESH_TOKEN) } returns auth()
val session = session(expiresInMs = 30_000L)
val before = System.currentTimeMillis()
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-access")
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("new-refresh")
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
val user = session.getAttribute(SessionKeys.USER) as SessionUser
assertThat(user.role).isEqualTo("ADMIN")
assertThat(user.mustChangePassword).isTrue()
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
}
@Test
fun `missing expiry with refresh token triggers refresh`() {
every { api.refresh(REFRESH_TOKEN) } returns auth()
val session = session(expiresInMs = null)
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-access")
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
}
@Test
fun `refresh response without new refresh token keeps the old one`() {
every { api.refresh(REFRESH_TOKEN) } returns auth(refreshToken = null)
val session = session(expiresInMs = 30_000L)
service.refreshIfNeeded(session, USER)
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo(REFRESH_TOKEN)
}
@Test
fun `rejected refresh means session expired`() {
every { api.refresh(REFRESH_TOKEN) } throws ApiException(401, "Refresh-токен истёк")
val session = session(expiresInMs = 30_000L)
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.EXPIRED)
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("old-access")
}
@Test
fun `forbidden refresh means session expired`() {
every { api.refresh(REFRESH_TOKEN) } throws ApiException(403, "Доступ запрещён")
val session = session(expiresInMs = 30_000L)
assertThat(service.refreshIfNeeded(session, USER)).isEqualTo(RefreshOutcome.EXPIRED)
}
@Test
fun `identity outage fails open`() {
every { api.refresh(REFRESH_TOKEN) } throws ApiException(502, "Сервис недоступен")
val session = session(expiresInMs = 30_000L)
val outcome = service.refreshIfNeeded(session, USER)
assertThat(outcome).isEqualTo(RefreshOutcome.FAILED_OPEN)
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("old-access")
}
@Test
fun `unexpected client error fails open`() {
every { api.refresh(REFRESH_TOKEN) } throws IllegalStateException("boom")
val session = session(expiresInMs = 30_000L)
assertThat(service.refreshIfNeeded(session, USER)).isEqualTo(RefreshOutcome.FAILED_OPEN)
}
private fun session(
expiresInMs: Long?,
refreshToken: String? = REFRESH_TOKEN,
): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, "old-access")
setAttribute(SessionKeys.USER, USER)
if (expiresInMs != null) {
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
}
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
}
private fun auth(refreshToken: String? = "new-refresh") =
AuthResponseDto(
token = "new-access",
refreshToken = refreshToken,
accessTokenExpiresIn = 900,
id = "test-id",
login = "test-login",
role = "ADMIN",
name = "Тестовый пользователь",
mustChangePassword = true,
)
private companion object {
const val REFRESH_TOKEN = "old-refresh"
val USER =
SessionUser(
id = "test-id",
login = "test-login",
role = "STUDENT",
name = "Тестовый пользователь",
mustChangePassword = false,
)
}
}