fix(web): перепроверять статус сессии
This commit is contained in:
@@ -42,4 +42,5 @@ data class MeResponse(
|
|||||||
val role: String,
|
val role: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
|
val active: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,22 +73,25 @@ class AuthService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureLoginAllowed(account: Account) {
|
private fun ensureLoginAllowed(account: Account) {
|
||||||
if (account.status != AccountStatus.ACTIVE) {
|
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureGroupOpen(account: Account) {
|
/** Причина запрета входа или null, если вход разрешён. Используется и в me(). */
|
||||||
val groupStatus = enrollments.findByStudentAccountId(account.id)?.group?.status
|
private fun loginDeniedReason(account: Account): String? {
|
||||||
if (groupStatus in setOf(GroupStatus.GRADUATED, GroupStatus.ARCHIVED)) {
|
if (account.status != AccountStatus.ACTIVE) {
|
||||||
throw UnauthorizedException("Группа закрыта")
|
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)
|
@Transactional(readOnly = true)
|
||||||
@@ -103,6 +106,7 @@ class AuthService(
|
|||||||
role = account.role.name,
|
role = account.role.name,
|
||||||
name = displayName(account),
|
name = displayName(account),
|
||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
|
active = loginDeniedReason(account) == null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,20 @@ class ApiClient(
|
|||||||
|
|
||||||
internal fun bearer(token: String): String = "Bearer $token"
|
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? =
|
internal fun <T> callOrNull(block: () -> T?): T? =
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ data class AuthResponseDto(
|
|||||||
val mustChangePassword: Boolean = false,
|
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(
|
data class LoginPayload(
|
||||||
val login: String,
|
val login: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
|
|||||||
@@ -2,11 +2,22 @@ package ru.ysu.lms.web.session
|
|||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.servlet.HandlerInterceptor
|
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
|
@Component
|
||||||
class AuthInterceptor : HandlerInterceptor {
|
class AuthInterceptor(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) : HandlerInterceptor {
|
||||||
override fun preHandle(
|
override fun preHandle(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
response: HttpServletResponse,
|
response: HttpServletResponse,
|
||||||
@@ -18,11 +29,84 @@ class AuthInterceptor : HandlerInterceptor {
|
|||||||
response.sendRedirect("/login")
|
response.sendRedirect("/login")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (!revalidate(session, user, response)) return false
|
||||||
|
val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
|
||||||
val uri = request.requestURI
|
val uri = request.requestURI
|
||||||
if (user.mustChangePassword && !uri.startsWith("/change-password")) {
|
if (current.mustChangePassword && !uri.startsWith("/change-password")) {
|
||||||
response.sendRedirect("/change-password")
|
response.sendRedirect("/change-password")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
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 {
|
object SessionKeys {
|
||||||
const val TOKEN = "lms.token"
|
const val TOKEN = "lms.token"
|
||||||
const val USER = "lms.user"
|
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