feat(auth): короткий access, refresh и 7-дневная sliding-сессия

This commit is contained in:
2026-10-28 13:26:22 +03:00
parent 08e5a62c0e
commit 3adfba9bec
29 changed files with 1261 additions and 14 deletions

View File

@@ -66,6 +66,8 @@ class JwtAuthenticationFilter(
path.startsWith("/actuator") ||
path == "/api/v1/auth/login" ||
path == "/api/v1/auth/setup" ||
path == "/api/v1/auth/refresh" ||
path == "/api/v1/auth/logout" ||
path == "/api/v1/auth/health" ||
path == "/api/v1/auth/jwks.json" ||
path.startsWith("/api/v1/content/")

View File

@@ -0,0 +1,42 @@
package ru.ysu.lms.gateway.security
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.http.HttpStatus
import org.springframework.mock.http.server.reactive.MockServerHttpRequest
import org.springframework.mock.web.server.MockServerWebExchange
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
class JwtAuthenticationFilterTest {
private val filter = JwtAuthenticationFilter(mockk<JwtDecoder>(relaxed = true))
@Test
fun `refresh and logout are public`() {
listOf("/api/v1/auth/refresh", "/api/v1/auth/logout").forEach { path ->
var called = false
val exchange = MockServerWebExchange.from(MockServerHttpRequest.post(path).build())
filter.filter(exchange, chain { called = true }).block()
assertTrue(called, "gateway did not pass $path")
}
}
@Test
fun `protected route without bearer token is rejected`() {
val exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/courses").build())
filter.filter(exchange, chain { }).block()
assertTrue(
exchange.response.statusCode == HttpStatus.UNAUTHORIZED,
"expected 401, got ${exchange.response.statusCode}",
)
}
private fun chain(onCall: () -> Unit): GatewayFilterChain =
GatewayFilterChain { _: ServerWebExchange ->
onCall()
Mono.empty()
}
}

View File

@@ -3,19 +3,23 @@ package ru.ysu.lms.identity.auth
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import java.util.UUID
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authService: AuthService,
private val refreshTokens: RefreshTokenService,
private val rsaKey: RSAKey,
) {
@GetMapping("/health")
@@ -36,6 +40,19 @@ class AuthController(
@Valid @RequestBody body: LoginRequest,
): AuthResponse = authService.login(body)
@PostMapping("/refresh")
fun refresh(
@Valid @RequestBody body: RefreshTokenRequest,
): AuthResponse = authService.refresh(body.refreshToken)
@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun logout(
@RequestBody(required = false) body: RefreshTokenRequest?,
) {
body?.refreshToken?.let(refreshTokens::revoke)
}
@GetMapping("/me")
fun me(
@AuthenticationPrincipal jwt: Jwt,

View File

@@ -27,6 +27,11 @@ data class ChangePasswordRequest(
val new2: String,
)
data class RefreshTokenRequest(
@field:NotBlank(message = "Укажите refresh-токен")
val refreshToken: String,
)
data class AuthResponse(
val token: String,
val id: String,
@@ -34,6 +39,8 @@ data class AuthResponse(
val role: String,
val name: String,
val mustChangePassword: Boolean,
val refreshToken: String? = null,
val accessTokenExpiresIn: Long = 0,
)
data class MeResponse(

View File

@@ -12,6 +12,7 @@ import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.GroupStatus
@@ -34,6 +35,7 @@ class AuthService(
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
private val backoff: LoginBackoff,
private val refreshTokens: RefreshTokenService,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@@ -72,6 +74,26 @@ class AuthService(
return toResponse(account, displayName(account))
}
/** Продлевает сессию: обменивает refresh-токен на новую пару access/refresh. */
fun refresh(rawToken: String): AuthResponse {
val rotation = refreshTokens.rotate(rawToken)
val account =
accounts
.findById(rotation.accountId)
.orElseThrow { UnauthorizedException(RefreshTokenService.SESSION_EXPIRED) }
return toResponse(account, displayName(account), rotation.refreshToken)
}
/** Проверяет, что текущее состояние аккаунта/студента/группы всё ещё разрешает сессию. */
@Transactional(readOnly = true)
fun ensureSessionAllowed(accountId: UUID) {
val account =
accounts
.findById(accountId)
.orElseThrow { UnauthorizedException(RefreshTokenService.SESSION_EXPIRED) }
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
private fun ensureLoginAllowed(account: Account) {
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
@@ -128,6 +150,7 @@ class AuthService(
account.tempPassword = null
account.updatedAt = Instant.now()
accounts.save(account)
refreshTokens.revokeAll(accountId)
}
private fun validatePassword(
@@ -155,6 +178,7 @@ class AuthService(
private fun toResponse(
account: Account,
name: String,
refreshToken: String = refreshTokens.issue(account.id),
): AuthResponse =
AuthResponse(
token = issueToken(account, name),
@@ -163,6 +187,8 @@ class AuthService(
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
refreshToken = refreshToken,
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
)
private fun issueToken(

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.identity.auth.refresh
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
/**
* Refresh-токен сессии. В базе лежит только SHA-256-хеш raw-токена.
* [replacedBy] задаёт следующее звено цепочки ротаций: повторное использование
* уже заменённого токена считается компрометацией.
*/
@Entity
@Table(name = "refresh_tokens")
class RefreshToken(
@Id
var id: UUID = Ids.v7(),
@Column(name = "account_id", nullable = false)
var accountId: UUID = Ids.v7(),
@Column(name = "token_hash", nullable = false, unique = true, length = 64)
var tokenHash: String = "",
@Column(name = "issued_at", nullable = false)
var issuedAt: Instant = Instant.now(),
@Column(name = "expires_at", nullable = false)
var expiresAt: Instant = Instant.now(),
@Column(name = "revoked_at")
var revokedAt: Instant? = null,
@Column(name = "replaced_by")
var replacedBy: UUID? = null,
)

View File

@@ -0,0 +1,10 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RefreshTokenRepository : JpaRepository<RefreshToken, UUID> {
fun findByTokenHash(tokenHash: String): RefreshToken?
fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List<RefreshToken>
}

View File

@@ -0,0 +1,127 @@
package ru.ysu.lms.identity.auth.refresh
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import ru.ysu.lms.common.Ids
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import java.security.MessageDigest
import java.security.SecureRandom
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.HexFormat
import java.util.UUID
/** Результат ротации: аккаунт сессии и новый raw refresh-токен. */
data class RefreshTokenRotation(
val accountId: UUID,
val refreshToken: String,
)
/**
* Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты,
* а сессия продлевается скользяще: каждый refresh возвращает новую пару.
*/
@Service
class RefreshTokenService(
private val tokens: RefreshTokenRepository,
// AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси.
@Lazy private val authService: AuthService,
@Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration,
) {
private val random = SecureRandom()
fun issue(accountId: UUID): String {
val now = Instant.now()
val raw = generateRaw()
tokens.save(newToken(accountId, raw, now))
return raw
}
/**
* Обменивает raw refresh-токен на новый. Старый токен помечается заменённым.
* Повторное использование заменённого токена трактуется как кража: все
* активные сессии аккаунта отзываются.
*/
fun rotate(raw: String): RefreshTokenRotation {
val token = activeTokenOrThrow(raw)
val now = Instant.now()
authService.ensureSessionAllowed(token.accountId)
val newId = Ids.v7()
val newRaw = generateRaw()
token.revokedAt = now
token.replacedBy = newId
tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId)))
return RefreshTokenRotation(token.accountId, newRaw)
}
private fun activeTokenOrThrow(raw: String): RefreshToken {
val token =
tokens.findByTokenHash(hash(raw))
?: throw UnauthorizedException(SESSION_EXPIRED)
if (token.revokedAt != null) rejectRevoked(token)
if (!token.expiresAt.isAfter(Instant.now())) {
throw UnauthorizedException(SESSION_EXPIRED)
}
return token
}
private fun rejectRevoked(token: RefreshToken): Nothing {
if (token.replacedBy != null) {
revokeAll(token.accountId)
throw UnauthorizedException(SESSION_REUSED)
}
throw UnauthorizedException(SESSION_EXPIRED)
}
/** Идемпотентный logout: неизвестный или уже отозванный токен игнорируется. */
fun revoke(raw: String) {
val token = tokens.findByTokenHash(hash(raw)) ?: return
if (token.revokedAt == null) {
token.revokedAt = Instant.now()
tokens.save(token)
}
}
/** Отзывает все активные refresh-токены аккаунта (смена пароля, reuse-атака). */
fun revokeAll(accountId: UUID) {
val now = Instant.now()
val active = tokens.findAllByAccountIdAndRevokedAtIsNull(accountId)
if (active.isEmpty()) return
active.forEach { it.revokedAt = now }
tokens.saveAll(active)
}
private fun newToken(
accountId: UUID,
raw: String,
now: Instant,
id: UUID = Ids.v7(),
): RefreshToken =
RefreshToken(
id = id,
accountId = accountId,
tokenHash = hash(raw),
issuedAt = now,
expiresAt = now.plus(ttl),
)
private fun generateRaw(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun hash(raw: String): String =
HexFormat
.of()
.formatHex(MessageDigest.getInstance("SHA-256").digest(raw.toByteArray(Charsets.UTF_8)))
companion object {
const val SESSION_EXPIRED = "Сессия истекла, войдите заново"
const val SESSION_REUSED = "Сессия уже использована, войдите заново"
}
}

View File

@@ -30,6 +30,8 @@ class SecurityConfig {
"/api/v1/auth/health",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/logout",
"/api/v1/auth/jwks.json",
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
"/api/v1/internal/**",

View File

@@ -40,5 +40,8 @@ lms:
internal:
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
jwt:
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}
# Короткоживущий access-токен; сессия продлевается refresh-токеном.
access-token-ttl: ${LMS_ACCESS_TOKEN_TTL:15m}
auth:
# Скользящая сессия: каждый refresh продлевает её на этот срок.
refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d}

View File

@@ -0,0 +1,13 @@
-- Refresh-токены сессий. Хранится только SHA-256-хеш raw-токена; цепочка
-- замен (replaced_by) позволяет обнаружить повторное использование старого токена.
CREATE TABLE identity.refresh_tokens (
id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES identity.accounts(id) ON DELETE CASCADE,
token_hash varchar(64) NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
replaced_by uuid
);
CREATE INDEX refresh_tokens_account_idx ON identity.refresh_tokens (account_id);
CREATE INDEX refresh_tokens_expires_idx ON identity.refresh_tokens (expires_at);

View File

@@ -17,6 +17,7 @@ import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.LoginBackoff
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.AccountRepository
@@ -64,6 +65,7 @@ class AuthServiceLoginBackoffTest {
jwtEncoder,
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
backoff,
mockk<RefreshTokenService>(relaxed = true),
)
}

View File

@@ -8,6 +8,7 @@ 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.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment
@@ -90,6 +91,7 @@ class AuthServiceMeActiveTest {
mockk<JwtEncoder>(),
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
LoginBackoff(mockk<LoginAttemptRepository>(relaxed = true)),
mockk<RefreshTokenService>(relaxed = true),
)
}
}

View File

@@ -0,0 +1,294 @@
package ru.ysu.lms.identity
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthResponse
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.identity.auth.ChangePasswordRequest
import ru.ysu.lms.identity.auth.LoginRequest
import ru.ysu.lms.identity.auth.RefreshTokenRequest
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
import ru.ysu.lms.identity.roster.CreateGroupRequest
import ru.ysu.lms.identity.roster.CreateStudentRequest
import ru.ysu.lms.identity.roster.Credential
import ru.ysu.lms.identity.roster.GroupService
import ru.ysu.lms.identity.roster.StudentLifecycleService
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
@SpringBootTest(properties = ["lms.promotion.scheduler-enabled=false"])
@Testcontainers
class RefreshTokenIntegrationTest {
companion object {
private val sequence = AtomicInteger(9200)
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired lateinit var authService: AuthService
@Autowired lateinit var refreshTokens: RefreshTokenService
@Autowired lateinit var groupService: GroupService
@Autowired lateinit var lifecycle: StudentLifecycleService
@Autowired lateinit var jwtDecoder: JwtDecoder
@Autowired lateinit var context: WebApplicationContext
@Autowired lateinit var jsonMapper: tools.jackson.databind.json.JsonMapper
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `login returns short lived access token and refresh token`() {
val student = createStudent()
val response = login(student)
assertThat(response.token).isNotBlank()
assertThat(response.refreshToken).isNotBlank()
assertThat(response.accessTokenExpiresIn).isGreaterThan(0).isLessThanOrEqualTo(3600)
val jwt = jwtDecoder.decode(response.token)
val lifetime = Duration.between(jwt.issuedAt, jwt.expiresAt)
assertThat(lifetime).isLessThan(Duration.ofDays(1))
assertThat(lifetime).isLessThanOrEqualTo(Duration.ofMinutes(15))
}
@Test
fun `refresh endpoint rotates token and logout endpoint revokes it`() {
val student = createStudent()
val loginResult =
mockMvc
.post("/api/v1/auth/login") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(LoginRequest(student.studentNo, student.password))
}.andExpect {
status { isOk() }
jsonPath("$.refreshToken") { isNotEmpty() }
jsonPath("$.accessTokenExpiresIn") { value(900) }
}.andReturn()
val original = readAuth(loginResult)
val refreshResult =
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(original.refreshToken!!))
}.andExpect {
status { isOk() }
jsonPath("$.refreshToken") { isNotEmpty() }
}.andReturn()
val rotated = readAuth(refreshResult)
assertThat(rotated.refreshToken).isNotEqualTo(original.refreshToken)
assertThat(rotated.token).isNotBlank()
mockMvc
.post("/api/v1/auth/logout") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(rotated.refreshToken!!))
}.andExpect {
status { isNoContent() }
}
mockMvc
.post("/api/v1/auth/refresh") {
contentType = MediaType.APPLICATION_JSON
content = jsonMapper.writeValueAsString(RefreshTokenRequest(rotated.refreshToken!!))
}.andExpect {
status { isUnauthorized() }
}
}
@Test
fun `logout without body is accepted`() {
mockMvc
.post("/api/v1/auth/logout")
.andExpect {
status { isNoContent() }
}
}
@Test
fun `refresh rotates the token and old token is rejected`() {
val student = createStudent()
val original = login(student).refreshToken!!
val rotated = refreshTokens.rotate(original)
assertThat(rotated.accountId).isEqualTo(UUID.fromString(student.id))
assertThat(rotated.refreshToken).isNotEqualTo(original)
val twice = refreshTokens.rotate(rotated.refreshToken)
assertThat(twice.refreshToken).isNotEqualTo(rotated.refreshToken)
assertThatThrownBy { refreshTokens.rotate(original) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_REUSED)
}
@Test
fun `reuse of a rotated token revokes every active session`() {
val student = createStudent()
val original = login(student).refreshToken!!
val rotated = refreshTokens.rotate(original)
assertThatThrownBy { refreshTokens.rotate(original) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_REUSED)
assertThatThrownBy { refreshTokens.rotate(rotated.refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `unknown refresh token is rejected`() {
assertThatThrownBy { refreshTokens.rotate("not-a-real-token") }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `logout revokes the refresh token idempotently`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
refreshTokens.revoke(refreshToken)
refreshTokens.revoke(refreshToken)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `password change revokes refresh tokens`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
authService.changePassword(
UUID.fromString(student.id),
ChangePasswordRequest(old = student.password, new = "newPassword123", new2 = "newPassword123"),
)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage(RefreshTokenService.SESSION_EXPIRED)
}
@Test
fun `expelled student cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
lifecycle.expel(UUID.fromString(student.id), null, null)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Доступ к системе закрыт")
}
@Test
fun `archived account cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
lifecycle.archive(UUID.fromString(student.id), null)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Аккаунт архивирован")
}
@Test
fun `student in archived group cannot refresh`() {
val student = createStudent()
val refreshToken = login(student).refreshToken!!
groupService.archiveGroup(student.groupId)
assertThatThrownBy { refreshTokens.rotate(refreshToken) }
.isInstanceOf(UnauthorizedException::class.java)
.hasMessage("Группа закрыта")
}
private fun createStudent(): StudentFixture {
val number = sequence.incrementAndGet()
val group =
groupService.create(
CreateGroupRequest(
stream = "РТ",
course = 1,
number = number.toShort(),
degree = "БО",
enrollmentYear = 2026,
),
)
val groupId = UUID.fromString(group.id)
val credential =
groupService.createStudent(
groupId,
CreateStudentRequest(
studentNo = number.toString(),
lastName = "Тестов",
firstName = "Тест",
),
null,
)
return StudentFixture(credential, groupId)
}
private fun login(student: StudentFixture): AuthResponse =
authService.login(LoginRequest(student.credential.studentNo, student.credential.password))
private fun readAuth(result: MvcResult): AuthResponse = jsonMapper.readValue(result.response.contentAsString, AuthResponse::class.java)
private data class StudentFixture(
val credential: Credential,
val groupId: UUID,
) {
val id: String get() = credential.id
val studentNo: String get() = credential.studentNo
val password: String get() = credential.password
}
}