diff --git a/.env.prod.example b/.env.prod.example index e96e0fb..567fcfd 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -20,3 +20,11 @@ LMS_BACKUP_KEEP_DAYS=14 # Внутренний токен между identity и progress (профиль студента). # Обязателен в production, задайте длинную случайную строку. 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 diff --git a/build.gradle.kts b/build.gradle.kts index 8f05f2f..7d70cfc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ import lms.evidence.EvidenceCheckTask val evidenceTestTasks = listOf( ":libs:markdown:test", + ":services:gateway:test", ":services:identity-service:test", ":services:academic-service:test", ":services:progress-service:test", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e01865e..25ddf2d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -45,6 +45,8 @@ services: environment: <<: [*db-env, *internal-env] 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: postgres: { condition: service_healthy } networks: [lms] @@ -105,6 +107,7 @@ services: environment: LMS_GATEWAY_URL: http://gateway:8080 SERVER_PORT: 8090 + LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d} ports: - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" depends_on: diff --git a/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt b/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt index 489de6c..7e67b6f 100644 --- a/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt +++ b/services/gateway/src/main/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilter.kt @@ -66,6 +66,8 @@ class JwtAuthenticationFilter( path.startsWith("/actuator") || path == "/api/v1/auth/login" || path == "/api/v1/auth/setup" || + path == "/api/v1/auth/refresh" || + path == "/api/v1/auth/logout" || path == "/api/v1/auth/health" || path == "/api/v1/auth/jwks.json" || path.startsWith("/api/v1/content/") diff --git a/services/gateway/src/test/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilterTest.kt b/services/gateway/src/test/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilterTest.kt new file mode 100644 index 0000000..60c84a2 --- /dev/null +++ b/services/gateway/src/test/kotlin/ru/ysu/lms/gateway/security/JwtAuthenticationFilterTest.kt @@ -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(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() + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthController.kt index e1aaef3..64f48c7 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthController.kt @@ -3,19 +3,23 @@ package ru.ysu.lms.identity.auth import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jose.jwk.RSAKey import jakarta.validation.Valid +import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.identity.auth.refresh.RefreshTokenService import java.util.UUID @RestController @RequestMapping("/api/v1/auth") class AuthController( private val authService: AuthService, + private val refreshTokens: RefreshTokenService, private val rsaKey: RSAKey, ) { @GetMapping("/health") @@ -36,6 +40,19 @@ class AuthController( @Valid @RequestBody body: LoginRequest, ): 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") fun me( @AuthenticationPrincipal jwt: Jwt, diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt index 5d93681..3ab085f 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt @@ -27,6 +27,11 @@ data class ChangePasswordRequest( val new2: String, ) +data class RefreshTokenRequest( + @field:NotBlank(message = "Укажите refresh-токен") + val refreshToken: String, +) + data class AuthResponse( val token: String, val id: String, @@ -34,6 +39,8 @@ data class AuthResponse( val role: String, val name: String, val mustChangePassword: Boolean, + val refreshToken: String? = null, + val accessTokenExpiresIn: Long = 0, ) data class MeResponse( diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt index d43cc0b..1a3a023 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt @@ -12,6 +12,7 @@ import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.ConflictException import ru.ysu.lms.common.NotFoundException 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.AccountStatus import ru.ysu.lms.identity.domain.GroupStatus @@ -34,6 +35,7 @@ class AuthService( private val jwtEncoder: JwtEncoder, private val jwtProperties: JwtProperties, private val backoff: LoginBackoff, + private val refreshTokens: RefreshTokenService, ) { fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN) @@ -72,6 +74,26 @@ class AuthService( 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) { loginDeniedReason(account)?.let { throw UnauthorizedException(it) } } @@ -128,6 +150,7 @@ class AuthService( account.tempPassword = null account.updatedAt = Instant.now() accounts.save(account) + refreshTokens.revokeAll(accountId) } private fun validatePassword( @@ -155,6 +178,7 @@ class AuthService( private fun toResponse( account: Account, name: String, + refreshToken: String = refreshTokens.issue(account.id), ): AuthResponse = AuthResponse( token = issueToken(account, name), @@ -163,6 +187,8 @@ class AuthService( role = account.role.name, name = name, mustChangePassword = account.mustChangePassword, + refreshToken = refreshToken, + accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds, ) private fun issueToken( diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt new file mode 100644 index 0000000..0364262 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt @@ -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, +) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt new file mode 100644 index 0000000..753c8ab --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt @@ -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 { + fun findByTokenHash(tokenHash: String): RefreshToken? + + fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt new file mode 100644 index 0000000..7050d59 --- /dev/null +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt @@ -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 = "Сессия уже использована, войдите заново" + } +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt index ddfb2fc..88060eb 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/security/SecurityConfig.kt @@ -30,6 +30,8 @@ class SecurityConfig { "/api/v1/auth/health", "/api/v1/auth/setup", "/api/v1/auth/login", + "/api/v1/auth/refresh", + "/api/v1/auth/logout", "/api/v1/auth/jwks.json", // Внутренние эндпоинты защищены сервисным токеном в самом контроллере. "/api/v1/internal/**", diff --git a/services/identity-service/src/main/resources/application.yml b/services/identity-service/src/main/resources/application.yml index 1e313fc..b9b3512 100644 --- a/services/identity-service/src/main/resources/application.yml +++ b/services/identity-service/src/main/resources/application.yml @@ -40,5 +40,8 @@ lms: internal: token: ${LMS_INTERNAL_TOKEN:dev-internal-token} jwt: - # Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml). - access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d} + # Короткоживущий access-токен; сессия продлевается refresh-токеном. + access-token-ttl: ${LMS_ACCESS_TOKEN_TTL:15m} + auth: + # Скользящая сессия: каждый refresh продлевает её на этот срок. + refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d} diff --git a/services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql b/services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql new file mode 100644 index 0000000..6425110 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql @@ -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); diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt index f904b44..f910f42 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceLoginBackoffTest.kt @@ -17,6 +17,7 @@ import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.LoginBackoff 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.LoginAttempt import ru.ysu.lms.identity.repo.AccountRepository @@ -64,6 +65,7 @@ class AuthServiceLoginBackoffTest { jwtEncoder, JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), backoff, + mockk(relaxed = true), ) } diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt index d32cd88..9cbe5ad 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt @@ -8,6 +8,7 @@ import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.oauth2.jwt.JwtEncoder import ru.ysu.lms.identity.auth.AuthService 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.AccountStatus import ru.ysu.lms.identity.domain.Enrollment @@ -90,6 +91,7 @@ class AuthServiceMeActiveTest { mockk(), JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), LoginBackoff(mockk(relaxed = true)), + mockk(relaxed = true), ) } } diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt new file mode 100644 index 0000000..2311c88 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt @@ -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(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 + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt index c0946c3..2a77446 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAuth.kt @@ -58,3 +58,31 @@ fun ApiClient.changePassword( .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() + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index a8650a5..8bfbff1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -11,6 +11,9 @@ data class HealthDto( @JsonIgnoreProperties(ignoreUnknown = true) data class AuthResponseDto( val token: String = "", + val refreshToken: String? = null, + /** Время жизни access-токена в секундах. */ + val accessTokenExpiresIn: Long = 0, val id: String = "", val login: String = "", val role: String = "", @@ -45,6 +48,14 @@ data class ChangePasswordPayload( val new2: String, ) +data class RefreshTokenPayload( + val refreshToken: String, +) + +data class LogoutPayload( + val refreshToken: String, +) + @JsonIgnoreProperties(ignoreUnknown = true) data class GroupDto( val id: String = "", diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index b9bbab8..6351af4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -13,6 +13,7 @@ import ru.ysu.lms.web.api.AuthResponseDto import ru.ysu.lms.web.api.adminExists 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.api.setup import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionUser @@ -99,6 +100,9 @@ class AuthPageController( val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login" return try { api.changePassword(token, old, new, new2) + // identity-service отзывает все refresh-токены при смене пароля. Access-токен + // доживает до истечения, но обновить его без повторного входа уже нельзя. + session.removeAttribute(SessionKeys.REFRESH_TOKEN) session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false)) "redirect:" + homeFor(user) } catch (ex: ApiException) { @@ -109,6 +113,11 @@ class AuthPageController( @PostMapping("/logout") 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() return "redirect:/login" } @@ -123,6 +132,11 @@ class AuthPageController( request.getSession(false)?.invalidate() val session = request.getSession(true) 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, SessionUser( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt index 5846fac..528c093 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt @@ -10,13 +10,15 @@ import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.MeDto /** - * Проверяет вход и периодически перепроверяет аккаунт в identity-service. - * Сессия BFF живёт дольше access-токена, поэтому без такой проверки отчисленный - * студент или архивная группа продолжали бы пользоваться порталом. + * Проверяет вход, поддерживает access-токен живым и периодически перепроверяет + * аккаунт в identity-service. Сессия BFF живёт дольше access-токена, поэтому без + * обновления и такой проверки отчисленный студент или архивная группа продолжали бы + * пользоваться порталом. */ @Component class AuthInterceptor( private val api: ApiClient, + private val tokenService: SessionTokenService, ) : HandlerInterceptor { override fun preHandle( request: HttpServletRequest, @@ -29,16 +31,36 @@ class AuthInterceptor( response.sendRedirect("/login") 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 uri = request.requestURI - if (current.mustChangePassword && !uri.startsWith("/change-password")) { - response.sendRedirect("/change-password") - return false - } + if (!current.mustChangePassword || request.requestURI.startsWith("/change-password")) return false + response.sendRedirect("/change-password") 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. */ private fun revalidate( session: HttpSession, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt new file mode 100644 index 0000000..e1bd6b0 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt @@ -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()) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt new file mode 100644 index 0000000..4ce8f48 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionTokenService.kt @@ -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 + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt index a288926..cdfdb86 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt @@ -14,6 +14,8 @@ data class SessionUser( object SessionKeys { 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 STATUS_CHECKED_AT = "lms.statusCheckedAt" } diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index 18e4885..822d5c8 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -4,14 +4,16 @@ server: forward-headers-strategy: framework servlet: session: - timeout: 30d + # BFF-сессия живёт 7 дней (sliding): вкладка может простаивать, access-токен обновляется сам. + timeout: ${LMS_SESSION_TIMEOUT:7d} cookie: + name: LMS_SESSION http-only: true same-site: lax # Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры # тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false. secure: ${LMS_SESSION_COOKIE_SECURE:true} - max-age: 30d + max-age: ${LMS_SESSION_TIMEOUT:7d} tracking-modes: cookie spring: diff --git a/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt new file mode 100644 index 0000000..c1606fb --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt @@ -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() + + 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, + ), + ) + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt index bd49b19..ddea49b 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt @@ -2,8 +2,12 @@ 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 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 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.MeDto +import ru.ysu.lms.web.api.refresh class AuthInterceptorTest { @RestController @@ -29,9 +35,20 @@ class AuthInterceptorTest { private val mockMvc: MockMvc = MockMvcBuilders .standaloneSetup(TestPage()) - .addInterceptors(AuthInterceptor(api)) + .addInterceptors(AuthInterceptor(api, SessionTokenService(api))) .build() + @BeforeEach + fun setUp() { + // refresh — extension-функция ApiClient, MockK подменяет её через файл-фасад. + mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt") + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + @Test fun `active session passes and caches check time`() { every { api.me(TOKEN) } returns me(active = true) @@ -132,12 +149,95 @@ class AuthInterceptorTest { .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( role: String = "STUDENT", mustChangePassword: Boolean = false, + expiresInMs: Long = 600_000L, + refreshToken: String? = REFRESH_TOKEN, ): MockHttpSession = MockHttpSession().apply { setAttribute(SessionKeys.TOKEN, TOKEN) + setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs) + if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken) setAttribute( SessionKeys.USER, SessionUser( @@ -165,5 +265,6 @@ class AuthInterceptorTest { private companion object { const val TOKEN = "test-token" + const val REFRESH_TOKEN = "test-refresh-token" } } diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt new file mode 100644 index 0000000..f50c32f --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt @@ -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() + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/SessionTokenServiceTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionTokenServiceTest.kt new file mode 100644 index 0000000..68b696e --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionTokenServiceTest.kt @@ -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() + 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, + ) + } +}