feat(auth): короткий access, refresh и 7-дневная sliding-сессия
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 = "Сессия уже использована, войдите заново"
|
||||
}
|
||||
}
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user