fix(web): перепроверять статус сессии
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user