diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt index 3f15085..5d93681 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthDtos.kt @@ -42,4 +42,5 @@ data class MeResponse( val role: String, val name: String, val mustChangePassword: Boolean, + val active: Boolean = true, ) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt index f9d30ad..d43cc0b 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/AuthService.kt @@ -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, ) } diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt new file mode 100644 index 0000000..d32cd88 --- /dev/null +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/AuthServiceMeActiveTest.kt @@ -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() + every { accounts.findById(account.id) } returns Optional.of(account) + val students = mockk() + every { students.findById(account.id) } returns Optional.of(student) + val enrollments = mockk() + every { enrollments.findByStudentAccountId(account.id) } returns enrollment + return AuthService( + accounts, + students, + enrollments, + mockk(), + mockk(), + JwtProperties(accessTokenTtl = Duration.ofMinutes(10)), + LoginBackoff(mockk(relaxed = true)), + ) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index dd156db..e6001ed 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -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 callOrNull(block: () -> T?): T? = try { block() diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 120a681..4dc80e5 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -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, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt index 299c70c..5846fac 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt @@ -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 + } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt index e65ef44..a288926 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt @@ -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" } diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt new file mode 100644 index 0000000..bd49b19 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt @@ -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() + + 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" + } +}