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

@@ -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()
}
}

View File

@@ -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 = "",

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.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(

View File

@@ -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,

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 {
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"
}

View File

@@ -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:

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.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"
}
}

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,
)
}
}