feat(auth): короткий access, refresh и 7-дневная sliding-сессия
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user