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

@@ -42,4 +42,5 @@ data class MeResponse(
val role: String,
val name: String,
val mustChangePassword: Boolean,
val active: Boolean = true,
)

View File

@@ -73,22 +73,25 @@ class AuthService(
}
private fun ensureLoginAllowed(account: Account) {
if (account.status != AccountStatus.ACTIVE) {
throw UnauthorizedException("Аккаунт архивирован")
}
if (account.role != LmsRole.STUDENT) return
val status = students.findById(account.id).map { it.status }.orElse(null)
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
throw UnauthorizedException("Доступ к системе закрыт")
}
ensureGroupOpen(account)
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
private fun ensureGroupOpen(account: Account) {
val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status
if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) {
throw UnauthorizedException("Группа закрыта")
/** Причина запрета входа или null, если вход разрешён. Используется и в me(). */
private fun loginDeniedReason(account: Account): String? {
if (account.status != AccountStatus.ACTIVE) {
return "Аккаунт архивирован"
}
if (account.role != LmsRole.STUDENT) return null
val status = students.findById(account.id).map { it.status }.orElse(null)
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
return "Доступ к системе закрыт"
}
return groupDeniedReason(account)
}
private fun groupDeniedReason(account: Account): String? {
val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status
return if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) "Группа закрыта" else null
}
@Transactional(readOnly = true)
@@ -103,6 +106,7 @@ class AuthService(
role = account.role.name,
name = displayName(account),
mustChangePassword = account.mustChangePassword,
active = loginDeniedReason(account) == null,
)
}

View File

@@ -0,0 +1,95 @@
package ru.ysu.lms.identity
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
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.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Duration
import java.util.Optional
class AuthServiceMeActiveTest {
@Test
fun `me is active for active student`() {
val account = studentAccount()
val service = service(account, Student(accountId = account.id, lastName = "Иванов", firstName = "Иван"))
val me = service.me(account.id)
assertThat(me.active).isTrue()
assertThat(me.name).isEqualTo("Иванов Иван")
}
@Test
fun `me is inactive for expelled student`() {
val account = studentAccount()
val student =
Student(
accountId = account.id,
lastName = "Иванов",
firstName = "Иван",
status = StudentStatus.EXPELLED,
)
val service = service(account, student)
assertThat(service.me(account.id).active).isFalse()
}
@Test
fun `me is inactive for archived group`() {
val account = studentAccount()
val student = Student(accountId = account.id, lastName = "Иванов", firstName = "Иван")
val group = GroupEntity(name = "ИВТ-21", status = GroupStatus.ARCHIVED)
val service = service(account, student, Enrollment(group = group))
assertThat(service.me(account.id).active).isFalse()
}
@Test
fun `me is inactive for archived account`() {
val account = studentAccount().apply { status = AccountStatus.ARCHIVED }
val service = service(account, Student(accountId = account.id, lastName = "Иванов", firstName = "Иван"))
assertThat(service.me(account.id).active).isFalse()
}
private fun studentAccount(): Account = Account(login = "2025001", passwordHash = "hash", role = LmsRole.STUDENT)
private fun service(
account: Account,
student: Student,
enrollment: Enrollment? = null,
): AuthService {
val accounts = mockk<AccountRepository>()
every { accounts.findById(account.id) } returns Optional.of(account)
val students = mockk<StudentRepository>()
every { students.findById(account.id) } returns Optional.of(student)
val enrollments = mockk<EnrollmentRepository>()
every { enrollments.findByStudentAccountId(account.id) } returns enrollment
return AuthService(
accounts,
students,
enrollments,
mockk<PasswordEncoder>(),
mockk<JwtEncoder>(),
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
LoginBackoff(mockk<LoginAttemptRepository>(relaxed = true)),
)
}
}

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