fix(web): перепроверять статус сессии

This commit is contained in:
2026-10-27 19:15:15 +03:00
parent 520a61085a
commit fba648ea54
8 changed files with 393 additions and 15 deletions

View File

@@ -33,6 +33,20 @@ class ApiClient(
internal fun bearer(token: String): String = "Bearer $token"
/**
* Текущее состояние аккаунта. Метод-член (а не extension), чтобы его можно было
* подменить в тестах AuthInterceptor.
*/
fun me(token: String): MeDto =
call {
client
.get()
.uri("/api/v1/auth/me")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve()
.body(MeDto::class.java)
}
internal fun <T> callOrNull(block: () -> T?): T? =
try {
block()

View File

@@ -18,6 +18,16 @@ data class AuthResponseDto(
val mustChangePassword: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class MeDto(
val id: String = "",
val login: String = "",
val role: String = "",
val name: String = "",
val mustChangePassword: Boolean = false,
val active: Boolean = true,
)
data class LoginPayload(
val login: String,
val password: String,

View File

@@ -2,11 +2,22 @@ package ru.ysu.lms.web.session
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.MeDto
/**
* Проверяет вход и периодически перепроверяет аккаунт в identity-service.
* Сессия BFF живёт дольше access-токена, поэтому без такой проверки отчисленный
* студент или архивная группа продолжали бы пользоваться порталом.
*/
@Component
class AuthInterceptor : HandlerInterceptor {
class AuthInterceptor(
private val api: ApiClient,
) : HandlerInterceptor {
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
@@ -18,11 +29,84 @@ class AuthInterceptor : HandlerInterceptor {
response.sendRedirect("/login")
return false
}
if (!revalidate(session, user, response)) return false
val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
val uri = request.requestURI
if (user.mustChangePassword && !uri.startsWith("/change-password")) {
if (current.mustChangePassword && !uri.startsWith("/change-password")) {
response.sendRedirect("/change-password")
return false
}
return true
}
/** true — запрос можно продолжать; false — сессия уже завершена и отправлен redirect. */
private fun revalidate(
session: HttpSession,
user: SessionUser,
response: HttpServletResponse,
): Boolean {
if (!isCheckDue(session)) return true
val token = session.getAttribute(SessionKeys.TOKEN) as? String
if (token == null) {
markChecked(session)
return true
}
return try {
val me = api.me(token)
markChecked(session)
if (!me.active) {
expire(session, response)
false
} else {
updateUser(session, user, me)
true
}
} catch (ex: ApiException) {
markChecked(session)
if (ex.status == 401 || ex.status == 403) {
expire(session, response)
false
} else {
true
}
} catch (_: Exception) {
// Identity недоступна: не разлогиниваем всех из-за сетевой ошибки.
markChecked(session)
true
}
}
private fun isCheckDue(session: HttpSession): Boolean {
val checkedAt = session.getAttribute(SessionKeys.STATUS_CHECKED_AT) as? Long ?: return true
return System.currentTimeMillis() - checkedAt >= STATUS_CHECK_INTERVAL_MS
}
private fun markChecked(session: HttpSession) {
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
}
private fun updateUser(
session: HttpSession,
user: SessionUser,
me: MeDto,
) {
if (me.role == user.role && me.mustChangePassword == user.mustChangePassword) return
session.setAttribute(
SessionKeys.USER,
user.copy(role = me.role, mustChangePassword = me.mustChangePassword),
)
}
private fun expire(
session: HttpSession,
response: HttpServletResponse,
) {
session.invalidate()
response.sendRedirect("/login?expired=1")
}
private companion object {
/** 30–60 секунд: достаточно быстро, чтобы закрыть доступ, и без запроса на каждую страницу. */
const val STATUS_CHECK_INTERVAL_MS = 45_000L
}
}

View File

@@ -15,4 +15,5 @@ data class SessionUser(
object SessionKeys {
const val TOKEN = "lms.token"
const val USER = "lms.user"
const val STATUS_CHECKED_AT = "lms.statusCheckedAt"
}

View File

@@ -0,0 +1,169 @@
package ru.ysu.lms.web.session
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
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.get
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 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.MeDto
class AuthInterceptorTest {
@RestController
private class TestPage {
@GetMapping("/test")
fun page(): String = "ok"
}
private val api = mockk<ApiClient>()
private val mockMvc: MockMvc =
MockMvcBuilders
.standaloneSetup(TestPage())
.addInterceptors(AuthInterceptor(api))
.build()
@Test
fun `active session passes and caches check time`() {
every { api.me(TOKEN) } returns me(active = true)
val session = sessionFor()
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
assertThat(session.getAttribute(SessionKeys.STATUS_CHECKED_AT)).isInstanceOf(Long::class.javaObjectType)
verify(exactly = 1) { api.me(TOKEN) }
}
@Test
fun `inactive account invalidates session and redirects to expired login`() {
every { api.me(TOKEN) } returns me(active = false)
val session = sessionFor()
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login?expired=1"))
assertThat(session.isInvalid).isTrue()
}
@Test
fun `expired or forbidden token invalidates session`() {
every { api.me(TOKEN) } throws ApiException(401, "Токен истёк")
val session = sessionFor()
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login?expired=1"))
assertThat(session.isInvalid).isTrue()
}
@Test
fun `fresh check timestamp skips identity call`() {
val session = sessionFor()
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
verify(exactly = 0) { api.me(any()) }
}
@Test
fun `stale check timestamp calls identity again`() {
every { api.me(TOKEN) } returns me(active = true)
val session = sessionFor()
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis() - 60_000L)
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
verify(exactly = 1) { api.me(TOKEN) }
}
@Test
fun `identity outage fails open and keeps session`() {
every { api.me(TOKEN) } throws ApiException(502, "Сервис недоступен")
val session = sessionFor()
mockMvc
.perform(get("/test").session(session))
.andExpect(status().isOk)
assertThat(session.isInvalid).isFalse()
assertThat(session.getAttribute(SessionKeys.STATUS_CHECKED_AT)).isInstanceOf(Long::class.javaObjectType)
}
@Test
fun `changed role and must change password are copied into session`() {
every { api.me(TOKEN) } returns me(active = true, role = "ADMIN", mustChangePassword = true)
val session = sessionFor(role = "STUDENT")
mockMvc
.perform(get("/test").session(session))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/change-password"))
val user = session.getAttribute(SessionKeys.USER) as SessionUser
assertThat(user.role).isEqualTo("ADMIN")
assertThat(user.mustChangePassword).isTrue()
}
@Test
fun `anonymous user is redirected to login`() {
mockMvc
.perform(get("/test"))
.andExpect(status().is3xxRedirection)
.andExpect(redirectedUrl("/login"))
}
private fun sessionFor(
role: String = "STUDENT",
mustChangePassword: Boolean = false,
): MockHttpSession =
MockHttpSession().apply {
setAttribute(SessionKeys.TOKEN, TOKEN)
setAttribute(
SessionKeys.USER,
SessionUser(
id = "test-id",
login = "test-login",
role = role,
name = "Тестовый пользователь",
mustChangePassword = mustChangePassword,
),
)
}
private fun me(
active: Boolean,
role: String = "STUDENT",
mustChangePassword: Boolean = false,
) = MeDto(
id = "test-id",
login = "test-login",
role = role,
name = "Тестовый пользователь",
mustChangePassword = mustChangePassword,
active = active,
)
private companion object {
const val TOKEN = "test-token"
}
}