feat(auth): короткий access, refresh и 7-дневная sliding-сессия
This commit is contained in:
@@ -20,3 +20,11 @@ LMS_BACKUP_KEEP_DAYS=14
|
|||||||
# Внутренний токен между identity и progress (профиль студента).
|
# Внутренний токен между identity и progress (профиль студента).
|
||||||
# Обязателен в production, задайте длинную случайную строку.
|
# Обязателен в production, задайте длинную случайную строку.
|
||||||
LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string
|
LMS_INTERNAL_TOKEN=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Сроки аутентификации и сессии (значения по умолчанию подходят).
|
||||||
|
# Access JWT: короткий, обновляется refresh-токеном.
|
||||||
|
LMS_ACCESS_TOKEN_TTL=15m
|
||||||
|
# Refresh-токен: 7 дней, ротация и отзыв при выходе/смене пароля.
|
||||||
|
LMS_REFRESH_TOKEN_TTL=7d
|
||||||
|
# Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически.
|
||||||
|
LMS_SESSION_TIMEOUT=7d
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lms.evidence.EvidenceCheckTask
|
|||||||
val evidenceTestTasks =
|
val evidenceTestTasks =
|
||||||
listOf(
|
listOf(
|
||||||
":libs:markdown:test",
|
":libs:markdown:test",
|
||||||
|
":services:gateway:test",
|
||||||
":services:identity-service:test",
|
":services:identity-service:test",
|
||||||
":services:academic-service:test",
|
":services:academic-service:test",
|
||||||
":services:progress-service:test",
|
":services:progress-service:test",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
<<: [*db-env, *internal-env]
|
<<: [*db-env, *internal-env]
|
||||||
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
|
LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity
|
||||||
|
LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m}
|
||||||
|
LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
networks: [lms]
|
networks: [lms]
|
||||||
@@ -105,6 +107,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
LMS_GATEWAY_URL: http://gateway:8080
|
LMS_GATEWAY_URL: http://gateway:8080
|
||||||
SERVER_PORT: 8090
|
SERVER_PORT: 8090
|
||||||
|
LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
|
- "127.0.0.1:${LMS_WEB_PORT:-8090}:8090"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class JwtAuthenticationFilter(
|
|||||||
path.startsWith("/actuator") ||
|
path.startsWith("/actuator") ||
|
||||||
path == "/api/v1/auth/login" ||
|
path == "/api/v1/auth/login" ||
|
||||||
path == "/api/v1/auth/setup" ||
|
path == "/api/v1/auth/setup" ||
|
||||||
|
path == "/api/v1/auth/refresh" ||
|
||||||
|
path == "/api/v1/auth/logout" ||
|
||||||
path == "/api/v1/auth/health" ||
|
path == "/api/v1/auth/health" ||
|
||||||
path == "/api/v1/auth/jwks.json" ||
|
path == "/api/v1/auth/jwks.json" ||
|
||||||
path.startsWith("/api/v1/content/")
|
path.startsWith("/api/v1/content/")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,23 @@ package ru.ysu.lms.identity.auth
|
|||||||
import com.nimbusds.jose.jwk.JWKSet
|
import com.nimbusds.jose.jwk.JWKSet
|
||||||
import com.nimbusds.jose.jwk.RSAKey
|
import com.nimbusds.jose.jwk.RSAKey
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import ru.ysu.lms.identity.auth.refresh.RefreshTokenService
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/auth")
|
@RequestMapping("/api/v1/auth")
|
||||||
class AuthController(
|
class AuthController(
|
||||||
private val authService: AuthService,
|
private val authService: AuthService,
|
||||||
|
private val refreshTokens: RefreshTokenService,
|
||||||
private val rsaKey: RSAKey,
|
private val rsaKey: RSAKey,
|
||||||
) {
|
) {
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
@@ -36,6 +40,19 @@ class AuthController(
|
|||||||
@Valid @RequestBody body: LoginRequest,
|
@Valid @RequestBody body: LoginRequest,
|
||||||
): AuthResponse = authService.login(body)
|
): 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")
|
@GetMapping("/me")
|
||||||
fun me(
|
fun me(
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ data class ChangePasswordRequest(
|
|||||||
val new2: String,
|
val new2: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RefreshTokenRequest(
|
||||||
|
@field:NotBlank(message = "Укажите refresh-токен")
|
||||||
|
val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
data class AuthResponse(
|
data class AuthResponse(
|
||||||
val token: String,
|
val token: String,
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -34,6 +39,8 @@ data class AuthResponse(
|
|||||||
val role: String,
|
val role: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val mustChangePassword: Boolean,
|
val mustChangePassword: Boolean,
|
||||||
|
val refreshToken: String? = null,
|
||||||
|
val accessTokenExpiresIn: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MeResponse(
|
data class MeResponse(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ru.ysu.lms.common.BadRequestException
|
|||||||
import ru.ysu.lms.common.ConflictException
|
import ru.ysu.lms.common.ConflictException
|
||||||
import ru.ysu.lms.common.NotFoundException
|
import ru.ysu.lms.common.NotFoundException
|
||||||
import ru.ysu.lms.common.UnauthorizedException
|
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.Account
|
||||||
import ru.ysu.lms.identity.domain.AccountStatus
|
import ru.ysu.lms.identity.domain.AccountStatus
|
||||||
import ru.ysu.lms.identity.domain.GroupStatus
|
import ru.ysu.lms.identity.domain.GroupStatus
|
||||||
@@ -34,6 +35,7 @@ class AuthService(
|
|||||||
private val jwtEncoder: JwtEncoder,
|
private val jwtEncoder: JwtEncoder,
|
||||||
private val jwtProperties: JwtProperties,
|
private val jwtProperties: JwtProperties,
|
||||||
private val backoff: LoginBackoff,
|
private val backoff: LoginBackoff,
|
||||||
|
private val refreshTokens: RefreshTokenService,
|
||||||
) {
|
) {
|
||||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||||
|
|
||||||
@@ -72,6 +74,26 @@ class AuthService(
|
|||||||
return toResponse(account, displayName(account))
|
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) {
|
private fun ensureLoginAllowed(account: Account) {
|
||||||
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
|
||||||
}
|
}
|
||||||
@@ -128,6 +150,7 @@ class AuthService(
|
|||||||
account.tempPassword = null
|
account.tempPassword = null
|
||||||
account.updatedAt = Instant.now()
|
account.updatedAt = Instant.now()
|
||||||
accounts.save(account)
|
accounts.save(account)
|
||||||
|
refreshTokens.revokeAll(accountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validatePassword(
|
private fun validatePassword(
|
||||||
@@ -155,6 +178,7 @@ class AuthService(
|
|||||||
private fun toResponse(
|
private fun toResponse(
|
||||||
account: Account,
|
account: Account,
|
||||||
name: String,
|
name: String,
|
||||||
|
refreshToken: String = refreshTokens.issue(account.id),
|
||||||
): AuthResponse =
|
): AuthResponse =
|
||||||
AuthResponse(
|
AuthResponse(
|
||||||
token = issueToken(account, name),
|
token = issueToken(account, name),
|
||||||
@@ -163,6 +187,8 @@ class AuthService(
|
|||||||
role = account.role.name,
|
role = account.role.name,
|
||||||
name = name,
|
name = name,
|
||||||
mustChangePassword = account.mustChangePassword,
|
mustChangePassword = account.mustChangePassword,
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun issueToken(
|
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/health",
|
||||||
"/api/v1/auth/setup",
|
"/api/v1/auth/setup",
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
"/api/v1/auth/logout",
|
||||||
"/api/v1/auth/jwks.json",
|
"/api/v1/auth/jwks.json",
|
||||||
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
|
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
|
||||||
"/api/v1/internal/**",
|
"/api/v1/internal/**",
|
||||||
|
|||||||
@@ -40,5 +40,8 @@ lms:
|
|||||||
internal:
|
internal:
|
||||||
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
|
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
|
||||||
jwt:
|
jwt:
|
||||||
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
|
# Короткоживущий access-токен; сессия продлевается refresh-токеном.
|
||||||
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}
|
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.AuthService
|
||||||
import ru.ysu.lms.identity.auth.LoginBackoff
|
import ru.ysu.lms.identity.auth.LoginBackoff
|
||||||
import ru.ysu.lms.identity.auth.LoginRequest
|
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.Account
|
||||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||||
import ru.ysu.lms.identity.repo.AccountRepository
|
import ru.ysu.lms.identity.repo.AccountRepository
|
||||||
@@ -64,6 +65,7 @@ class AuthServiceLoginBackoffTest {
|
|||||||
jwtEncoder,
|
jwtEncoder,
|
||||||
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
||||||
backoff,
|
backoff,
|
||||||
|
mockk<RefreshTokenService>(relaxed = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
|||||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||||
import ru.ysu.lms.identity.auth.AuthService
|
import ru.ysu.lms.identity.auth.AuthService
|
||||||
import ru.ysu.lms.identity.auth.LoginBackoff
|
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.Account
|
||||||
import ru.ysu.lms.identity.domain.AccountStatus
|
import ru.ysu.lms.identity.domain.AccountStatus
|
||||||
import ru.ysu.lms.identity.domain.Enrollment
|
import ru.ysu.lms.identity.domain.Enrollment
|
||||||
@@ -90,6 +91,7 @@ class AuthServiceMeActiveTest {
|
|||||||
mockk<JwtEncoder>(),
|
mockk<JwtEncoder>(),
|
||||||
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
||||||
LoginBackoff(mockk<LoginAttemptRepository>(relaxed = true)),
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,3 +58,31 @@ fun ApiClient.changePassword(
|
|||||||
.toBodilessEntity()
|
.toBodilessEntity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Обменивает refresh-токен на новую пару access/refresh. */
|
||||||
|
fun ApiClient.refresh(refreshToken: String): AuthResponseDto =
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/auth/refresh")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(RefreshTokenPayload(refreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.body(AuthResponseDto::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отзывает refresh-токен. Вызывающие стороны используют runCatching:
|
||||||
|
* недоступность identity не должна мешать выходу из BFF-сессии.
|
||||||
|
*/
|
||||||
|
fun ApiClient.logout(refreshToken: String) {
|
||||||
|
call {
|
||||||
|
client
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/auth/logout")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(LogoutPayload(refreshToken))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ data class HealthDto(
|
|||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class AuthResponseDto(
|
data class AuthResponseDto(
|
||||||
val token: String = "",
|
val token: String = "",
|
||||||
|
val refreshToken: String? = null,
|
||||||
|
/** Время жизни access-токена в секундах. */
|
||||||
|
val accessTokenExpiresIn: Long = 0,
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val login: String = "",
|
val login: String = "",
|
||||||
val role: String = "",
|
val role: String = "",
|
||||||
@@ -45,6 +48,14 @@ data class ChangePasswordPayload(
|
|||||||
val new2: String,
|
val new2: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RefreshTokenPayload(
|
||||||
|
val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LogoutPayload(
|
||||||
|
val refreshToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class GroupDto(
|
data class GroupDto(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ru.ysu.lms.web.api.AuthResponseDto
|
|||||||
import ru.ysu.lms.web.api.adminExists
|
import ru.ysu.lms.web.api.adminExists
|
||||||
import ru.ysu.lms.web.api.changePassword
|
import ru.ysu.lms.web.api.changePassword
|
||||||
import ru.ysu.lms.web.api.login
|
import ru.ysu.lms.web.api.login
|
||||||
|
import ru.ysu.lms.web.api.logout
|
||||||
import ru.ysu.lms.web.api.setup
|
import ru.ysu.lms.web.api.setup
|
||||||
import ru.ysu.lms.web.session.SessionKeys
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
import ru.ysu.lms.web.session.SessionUser
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
@@ -99,6 +100,9 @@ class AuthPageController(
|
|||||||
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
|
||||||
return try {
|
return try {
|
||||||
api.changePassword(token, old, new, new2)
|
api.changePassword(token, old, new, new2)
|
||||||
|
// identity-service отзывает все refresh-токены при смене пароля. Access-токен
|
||||||
|
// доживает до истечения, но обновить его без повторного входа уже нельзя.
|
||||||
|
session.removeAttribute(SessionKeys.REFRESH_TOKEN)
|
||||||
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
|
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
|
||||||
"redirect:" + homeFor(user)
|
"redirect:" + homeFor(user)
|
||||||
} catch (ex: ApiException) {
|
} catch (ex: ApiException) {
|
||||||
@@ -109,6 +113,11 @@ class AuthPageController(
|
|||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
fun logout(session: HttpSession): String {
|
fun logout(session: HttpSession): String {
|
||||||
|
val refreshToken = session.getAttribute(SessionKeys.REFRESH_TOKEN) as? String
|
||||||
|
if (refreshToken != null) {
|
||||||
|
// Best-effort: даже если identity недоступна, локальную сессию завершаем.
|
||||||
|
runCatching { api.logout(refreshToken) }
|
||||||
|
}
|
||||||
session.invalidate()
|
session.invalidate()
|
||||||
return "redirect:/login"
|
return "redirect:/login"
|
||||||
}
|
}
|
||||||
@@ -123,6 +132,11 @@ class AuthPageController(
|
|||||||
request.getSession(false)?.invalidate()
|
request.getSession(false)?.invalidate()
|
||||||
val session = request.getSession(true)
|
val session = request.getSession(true)
|
||||||
session.setAttribute(SessionKeys.TOKEN, auth.token)
|
session.setAttribute(SessionKeys.TOKEN, auth.token)
|
||||||
|
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
|
||||||
|
session.setAttribute(
|
||||||
|
SessionKeys.TOKEN_EXPIRES_AT,
|
||||||
|
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000,
|
||||||
|
)
|
||||||
session.setAttribute(
|
session.setAttribute(
|
||||||
SessionKeys.USER,
|
SessionKeys.USER,
|
||||||
SessionUser(
|
SessionUser(
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import ru.ysu.lms.web.api.ApiException
|
|||||||
import ru.ysu.lms.web.api.MeDto
|
import ru.ysu.lms.web.api.MeDto
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет вход и периодически перепроверяет аккаунт в identity-service.
|
* Проверяет вход, поддерживает access-токен живым и периодически перепроверяет
|
||||||
* Сессия BFF живёт дольше access-токена, поэтому без такой проверки отчисленный
|
* аккаунт в identity-service. Сессия BFF живёт дольше access-токена, поэтому без
|
||||||
* студент или архивная группа продолжали бы пользоваться порталом.
|
* обновления и такой проверки отчисленный студент или архивная группа продолжали бы
|
||||||
|
* пользоваться порталом.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
class AuthInterceptor(
|
class AuthInterceptor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
|
private val tokenService: SessionTokenService,
|
||||||
) : HandlerInterceptor {
|
) : HandlerInterceptor {
|
||||||
override fun preHandle(
|
override fun preHandle(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
@@ -29,16 +31,36 @@ class AuthInterceptor(
|
|||||||
response.sendRedirect("/login")
|
response.sendRedirect("/login")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!revalidate(session, user, response)) return false
|
if (!ensureFreshToken(session, user, response)) return false
|
||||||
|
val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
|
||||||
|
if (!revalidate(session, refreshedUser, response)) return false
|
||||||
|
return !redirectIfPasswordMustChange(session, refreshedUser, request, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */
|
||||||
|
private fun redirectIfPasswordMustChange(
|
||||||
|
session: HttpSession,
|
||||||
|
user: SessionUser,
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
): Boolean {
|
||||||
val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
|
val current = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user
|
||||||
val uri = request.requestURI
|
if (!current.mustChangePassword || request.requestURI.startsWith("/change-password")) return false
|
||||||
if (current.mustChangePassword && !uri.startsWith("/change-password")) {
|
response.sendRedirect("/change-password")
|
||||||
response.sendRedirect("/change-password")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** true — токен актуален; false — refresh невозможен, сессия завершена и отправлен redirect. */
|
||||||
|
private fun ensureFreshToken(
|
||||||
|
session: HttpSession,
|
||||||
|
user: SessionUser,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
): Boolean {
|
||||||
|
if (tokenService.refreshIfNeeded(session, user) != RefreshOutcome.EXPIRED) return true
|
||||||
|
expire(session, response)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** true — запрос можно продолжать; false — сессия уже завершена и отправлен redirect. */
|
/** true — запрос можно продолжать; false — сессия уже завершена и отправлен redirect. */
|
||||||
private fun revalidate(
|
private fun revalidate(
|
||||||
session: HttpSession,
|
session: HttpSession,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package ru.ysu.lms.web.session
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.ResponseCookie
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-сессия: серверный timeout продлевается при активности, но cookie
|
||||||
|
* с абсолютным Max-Age сама по себе не продлевается. Перевыпускаем cookie на
|
||||||
|
* каждом ответе, чтобы через 7 дней активной работы не требовать пароль.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class SessionCookieRefreshFilter(
|
||||||
|
@Value("\${server.servlet.session.cookie.name:LMS_SESSION}") private val cookieName: String,
|
||||||
|
@Value("\${server.servlet.session.timeout:7d}") private val sessionTtl: Duration,
|
||||||
|
@Value("\${server.servlet.session.cookie.secure:true}") private val secureCookie: Boolean,
|
||||||
|
) : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain,
|
||||||
|
) {
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
val session = request.getSession(false) ?: return
|
||||||
|
if (response.isCommitted) return
|
||||||
|
val cookie =
|
||||||
|
ResponseCookie
|
||||||
|
.from(cookieName, session.id)
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(secureCookie || request.isSecure)
|
||||||
|
.path(request.contextPath.ifBlank { "/" })
|
||||||
|
.sameSite("Lax")
|
||||||
|
.maxAge(sessionTtl)
|
||||||
|
.build()
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package ru.ysu.lms.web.session
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.refresh
|
||||||
|
|
||||||
|
/** Результат проверки access-токена BFF-сессии. */
|
||||||
|
enum class RefreshOutcome {
|
||||||
|
/** Токен актуален (или успешно обновлён). */
|
||||||
|
OK,
|
||||||
|
|
||||||
|
/** Обновить токен нельзя: требуется повторный вход. */
|
||||||
|
EXPIRED,
|
||||||
|
|
||||||
|
/** Identity недоступна — не разлогиниваем пользователей из-за сбоя. */
|
||||||
|
FAILED_OPEN,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поддерживает access-токен BFF-сессии живым: обновляет его по refresh-токену
|
||||||
|
* незадолго до истечения. Сессия при этом живёт 7 дней (sliding), а access-токен — 15 минут.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class SessionTokenService(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* @param user текущий пользователь сессии: после refresh роль и флаг смены пароля
|
||||||
|
* могли измениться на стороне identity-service.
|
||||||
|
*/
|
||||||
|
fun refreshIfNeeded(
|
||||||
|
session: HttpSession,
|
||||||
|
user: SessionUser,
|
||||||
|
): RefreshOutcome {
|
||||||
|
val expiresAt = session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as? Long
|
||||||
|
if (expiresAt != null && System.currentTimeMillis() < expiresAt - REFRESH_SKEW_MS) {
|
||||||
|
return RefreshOutcome.OK
|
||||||
|
}
|
||||||
|
val refreshToken =
|
||||||
|
session.getAttribute(SessionKeys.REFRESH_TOKEN) as? String
|
||||||
|
?: return RefreshOutcome.EXPIRED
|
||||||
|
return try {
|
||||||
|
val auth = api.refresh(refreshToken)
|
||||||
|
session.setAttribute(SessionKeys.TOKEN, auth.token)
|
||||||
|
auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) }
|
||||||
|
session.setAttribute(
|
||||||
|
SessionKeys.TOKEN_EXPIRES_AT,
|
||||||
|
System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000,
|
||||||
|
)
|
||||||
|
session.setAttribute(
|
||||||
|
SessionKeys.USER,
|
||||||
|
user.copy(role = auth.role, mustChangePassword = auth.mustChangePassword),
|
||||||
|
)
|
||||||
|
RefreshOutcome.OK
|
||||||
|
} catch (ex: ApiException) {
|
||||||
|
if (ex.status == 401 || ex.status == 403) RefreshOutcome.EXPIRED else RefreshOutcome.FAILED_OPEN
|
||||||
|
} catch (_: Exception) {
|
||||||
|
RefreshOutcome.FAILED_OPEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Обновляем заранее, чтобы запрос не ушёл с уже истёкшим access-токеном. */
|
||||||
|
const val REFRESH_SKEW_MS = 120_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ data class SessionUser(
|
|||||||
|
|
||||||
object SessionKeys {
|
object SessionKeys {
|
||||||
const val TOKEN = "lms.token"
|
const val TOKEN = "lms.token"
|
||||||
|
const val REFRESH_TOKEN = "lms.refreshToken"
|
||||||
|
const val TOKEN_EXPIRES_AT = "lms.tokenExpiresAt"
|
||||||
const val USER = "lms.user"
|
const val USER = "lms.user"
|
||||||
const val STATUS_CHECKED_AT = "lms.statusCheckedAt"
|
const val STATUS_CHECKED_AT = "lms.statusCheckedAt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ server:
|
|||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
servlet:
|
servlet:
|
||||||
session:
|
session:
|
||||||
timeout: 30d
|
# BFF-сессия живёт 7 дней (sliding): вкладка может простаивать, access-токен обновляется сам.
|
||||||
|
timeout: ${LMS_SESSION_TIMEOUT:7d}
|
||||||
cookie:
|
cookie:
|
||||||
|
name: LMS_SESSION
|
||||||
http-only: true
|
http-only: true
|
||||||
same-site: lax
|
same-site: lax
|
||||||
# Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры
|
# Прод работает по HTTPS (nginx + TLS); локально на http://localhost браузеры
|
||||||
# тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false.
|
# тоже принимают Secure-cookie. Отключается переменной LMS_SESSION_COOKIE_SECURE=false.
|
||||||
secure: ${LMS_SESSION_COOKIE_SECURE:true}
|
secure: ${LMS_SESSION_COOKIE_SECURE:true}
|
||||||
max-age: 30d
|
max-age: ${LMS_SESSION_TIMEOUT:7d}
|
||||||
tracking-modes: cookie
|
tracking-modes: cookie
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package ru.ysu.lms.web.auth
|
||||||
|
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
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.post
|
||||||
|
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 ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AuthResponseDto
|
||||||
|
import ru.ysu.lms.web.api.changePassword
|
||||||
|
import ru.ysu.lms.web.api.login
|
||||||
|
import ru.ysu.lms.web.api.logout
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
import ru.ysu.lms.web.session.SessionUser
|
||||||
|
|
||||||
|
class AuthPageControllerTest {
|
||||||
|
private val api = mockk<ApiClient>()
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc =
|
||||||
|
MockMvcBuilders
|
||||||
|
.standaloneSetup(AuthPageController(api))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `login stores access and refresh tokens with expiry`() {
|
||||||
|
every { api.login("student", "secret") } returns
|
||||||
|
AuthResponseDto(
|
||||||
|
token = "access",
|
||||||
|
refreshToken = "refresh",
|
||||||
|
accessTokenExpiresIn = 900,
|
||||||
|
id = "s1",
|
||||||
|
login = "student",
|
||||||
|
role = "STUDENT",
|
||||||
|
name = "Студент",
|
||||||
|
mustChangePassword = false,
|
||||||
|
)
|
||||||
|
val before = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val result =
|
||||||
|
mockMvc
|
||||||
|
.perform(post("/login").param("login", "student").param("password", "secret"))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/student"))
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val session = result.request.getSession(false) as MockHttpSession
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("refresh")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
|
||||||
|
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).login).isEqualTo("student")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `login without refresh token still creates the session`() {
|
||||||
|
every { api.login("student", "secret") } returns
|
||||||
|
AuthResponseDto(
|
||||||
|
token = "access",
|
||||||
|
accessTokenExpiresIn = 900,
|
||||||
|
id = "s1",
|
||||||
|
login = "student",
|
||||||
|
role = "STUDENT",
|
||||||
|
name = "Студент",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
mockMvc
|
||||||
|
.perform(post("/login").param("login", "student").param("password", "secret"))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val session = result.request.getSession(false) as MockHttpSession
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logout revokes refresh token and invalidates session`() {
|
||||||
|
every { api.logout("refresh") } just Runs
|
||||||
|
val session = sessionFor()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(post("/logout").session(session))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/login"))
|
||||||
|
|
||||||
|
verify(exactly = 1) { api.logout("refresh") }
|
||||||
|
assertThat(session.isInvalid).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `logout still ends the session when identity is unavailable`() {
|
||||||
|
every { api.logout("refresh") } throws ApiException(502, "Сервис недоступен")
|
||||||
|
val session = sessionFor()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(post("/logout").session(session))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/login"))
|
||||||
|
|
||||||
|
assertThat(session.isInvalid).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `password change clears refresh token but keeps access token`() {
|
||||||
|
every { api.changePassword("access", "old", "new", "new") } just Runs
|
||||||
|
val session = sessionFor()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
post("/change-password")
|
||||||
|
.session(session)
|
||||||
|
.param("old", "old")
|
||||||
|
.param("new", "new")
|
||||||
|
.param("new2", "new"),
|
||||||
|
).andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/student"))
|
||||||
|
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull()
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("access")
|
||||||
|
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).mustChangePassword).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionFor(): MockHttpSession =
|
||||||
|
MockHttpSession().apply {
|
||||||
|
setAttribute(SessionKeys.TOKEN, "access")
|
||||||
|
setAttribute(SessionKeys.REFRESH_TOKEN, "refresh")
|
||||||
|
setAttribute(
|
||||||
|
SessionKeys.USER,
|
||||||
|
SessionUser(
|
||||||
|
id = "s1",
|
||||||
|
login = "student",
|
||||||
|
role = "STUDENT",
|
||||||
|
name = "Студент",
|
||||||
|
mustChangePassword = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@ package ru.ysu.lms.web.session
|
|||||||
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.springframework.mock.web.MockHttpSession
|
import org.springframework.mock.web.MockHttpSession
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
@@ -15,7 +19,9 @@ import org.springframework.web.bind.annotation.GetMapping
|
|||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
import ru.ysu.lms.web.api.ApiException
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AuthResponseDto
|
||||||
import ru.ysu.lms.web.api.MeDto
|
import ru.ysu.lms.web.api.MeDto
|
||||||
|
import ru.ysu.lms.web.api.refresh
|
||||||
|
|
||||||
class AuthInterceptorTest {
|
class AuthInterceptorTest {
|
||||||
@RestController
|
@RestController
|
||||||
@@ -29,9 +35,20 @@ class AuthInterceptorTest {
|
|||||||
private val mockMvc: MockMvc =
|
private val mockMvc: MockMvc =
|
||||||
MockMvcBuilders
|
MockMvcBuilders
|
||||||
.standaloneSetup(TestPage())
|
.standaloneSetup(TestPage())
|
||||||
.addInterceptors(AuthInterceptor(api))
|
.addInterceptors(AuthInterceptor(api, SessionTokenService(api)))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
// refresh — extension-функция ApiClient, MockK подменяет её через файл-фасад.
|
||||||
|
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `active session passes and caches check time`() {
|
fun `active session passes and caches check time`() {
|
||||||
every { api.me(TOKEN) } returns me(active = true)
|
every { api.me(TOKEN) } returns me(active = true)
|
||||||
@@ -132,12 +149,95 @@ class AuthInterceptorTest {
|
|||||||
.andExpect(redirectedUrl("/login"))
|
.andExpect(redirectedUrl("/login"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `access token expiring soon is refreshed and session updated`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } returns
|
||||||
|
AuthResponseDto(
|
||||||
|
token = "new-token",
|
||||||
|
refreshToken = "new-refresh-token",
|
||||||
|
accessTokenExpiresIn = 900,
|
||||||
|
id = "test-id",
|
||||||
|
login = "test-login",
|
||||||
|
role = "TEACHER",
|
||||||
|
name = "Тестовый пользователь",
|
||||||
|
mustChangePassword = false,
|
||||||
|
)
|
||||||
|
val session = sessionFor(expiresInMs = 30_000L)
|
||||||
|
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
|
||||||
|
val before = System.currentTimeMillis()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/test").session(session))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-token")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("new-refresh-token")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
|
||||||
|
assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).role).isEqualTo("TEACHER")
|
||||||
|
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fresh access token does not trigger refresh`() {
|
||||||
|
val session = sessionFor(expiresInMs = 600_000L)
|
||||||
|
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/test").session(session))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
verify(exactly = 0) { api.refresh(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejected refresh invalidates session and redirects to expired login`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws ApiException(401, "Refresh-токен истёк")
|
||||||
|
val session = sessionFor(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/test").session(session))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/login?expired=1"))
|
||||||
|
|
||||||
|
assertThat(session.isInvalid).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing refresh token with expired access token redirects to login`() {
|
||||||
|
val session = sessionFor(expiresInMs = 30_000L, refreshToken = null)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/test").session(session))
|
||||||
|
.andExpect(status().is3xxRedirection)
|
||||||
|
.andExpect(redirectedUrl("/login?expired=1"))
|
||||||
|
|
||||||
|
assertThat(session.isInvalid).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `identity outage during refresh fails open and keeps session`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws ApiException(502, "Сервис недоступен")
|
||||||
|
val session = sessionFor(expiresInMs = 30_000L)
|
||||||
|
session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis())
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/test").session(session))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
|
||||||
|
assertThat(session.isInvalid).isFalse()
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo(TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sessionFor(
|
private fun sessionFor(
|
||||||
role: String = "STUDENT",
|
role: String = "STUDENT",
|
||||||
mustChangePassword: Boolean = false,
|
mustChangePassword: Boolean = false,
|
||||||
|
expiresInMs: Long = 600_000L,
|
||||||
|
refreshToken: String? = REFRESH_TOKEN,
|
||||||
): MockHttpSession =
|
): MockHttpSession =
|
||||||
MockHttpSession().apply {
|
MockHttpSession().apply {
|
||||||
setAttribute(SessionKeys.TOKEN, TOKEN)
|
setAttribute(SessionKeys.TOKEN, TOKEN)
|
||||||
|
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
|
||||||
|
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
|
||||||
setAttribute(
|
setAttribute(
|
||||||
SessionKeys.USER,
|
SessionKeys.USER,
|
||||||
SessionUser(
|
SessionUser(
|
||||||
@@ -165,5 +265,6 @@ class AuthInterceptorTest {
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TOKEN = "test-token"
|
const val TOKEN = "test-token"
|
||||||
|
const val REFRESH_TOKEN = "test-refresh-token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.ysu.lms.web.session
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockFilterChain
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
class SessionCookieRefreshFilterTest {
|
||||||
|
private val filter = SessionCookieRefreshFilter("LMS_SESSION", Duration.ofDays(7), secureCookie = false)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshes cookie max age on every existing session`() {
|
||||||
|
val request = MockHttpServletRequest()
|
||||||
|
request.getSession(true)
|
||||||
|
val response = MockHttpServletResponse()
|
||||||
|
|
||||||
|
filter.doFilter(request, response, MockFilterChain())
|
||||||
|
|
||||||
|
val cookie = response.getHeader("Set-Cookie")
|
||||||
|
assertThat(cookie).contains("LMS_SESSION=")
|
||||||
|
assertThat(cookie).contains("Max-Age=604800")
|
||||||
|
assertThat(cookie).contains("HttpOnly")
|
||||||
|
assertThat(cookie).contains("SameSite=Lax")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not create cookie without session`() {
|
||||||
|
val response = MockHttpServletResponse()
|
||||||
|
|
||||||
|
filter.doFilter(MockHttpServletRequest(), response, MockFilterChain())
|
||||||
|
|
||||||
|
assertThat(response.getHeader("Set-Cookie")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package ru.ysu.lms.web.session
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.mock.web.MockHttpSession
|
||||||
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
|
import ru.ysu.lms.web.api.ApiException
|
||||||
|
import ru.ysu.lms.web.api.AuthResponseDto
|
||||||
|
import ru.ysu.lms.web.api.refresh
|
||||||
|
|
||||||
|
class SessionTokenServiceTest {
|
||||||
|
private val api = mockk<ApiClient>()
|
||||||
|
private val service = SessionTokenService(api)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic("ru.ysu.lms.web.api.ApiClientAuthKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fresh access token needs no refresh`() {
|
||||||
|
val session = session(expiresInMs = 600_000L)
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
|
||||||
|
verify(exactly = 0) { api.refresh(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing expiry and no refresh token means expired`() {
|
||||||
|
val session = session(expiresInMs = null, refreshToken = null)
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.EXPIRED)
|
||||||
|
verify(exactly = 0) { api.refresh(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `token expiring soon is refreshed and session is updated`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } returns auth()
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
val before = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-access")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo("new-refresh")
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN_EXPIRES_AT) as Long).isGreaterThan(before)
|
||||||
|
val user = session.getAttribute(SessionKeys.USER) as SessionUser
|
||||||
|
assertThat(user.role).isEqualTo("ADMIN")
|
||||||
|
assertThat(user.mustChangePassword).isTrue()
|
||||||
|
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing expiry with refresh token triggers refresh`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } returns auth()
|
||||||
|
val session = session(expiresInMs = null)
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.OK)
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("new-access")
|
||||||
|
verify(exactly = 1) { api.refresh(REFRESH_TOKEN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh response without new refresh token keeps the old one`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } returns auth(refreshToken = null)
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isEqualTo(REFRESH_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejected refresh means session expired`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws ApiException(401, "Refresh-токен истёк")
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.EXPIRED)
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("old-access")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `forbidden refresh means session expired`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws ApiException(403, "Доступ запрещён")
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
assertThat(service.refreshIfNeeded(session, USER)).isEqualTo(RefreshOutcome.EXPIRED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `identity outage fails open`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws ApiException(502, "Сервис недоступен")
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
val outcome = service.refreshIfNeeded(session, USER)
|
||||||
|
|
||||||
|
assertThat(outcome).isEqualTo(RefreshOutcome.FAILED_OPEN)
|
||||||
|
assertThat(session.getAttribute(SessionKeys.TOKEN)).isEqualTo("old-access")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unexpected client error fails open`() {
|
||||||
|
every { api.refresh(REFRESH_TOKEN) } throws IllegalStateException("boom")
|
||||||
|
val session = session(expiresInMs = 30_000L)
|
||||||
|
|
||||||
|
assertThat(service.refreshIfNeeded(session, USER)).isEqualTo(RefreshOutcome.FAILED_OPEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
expiresInMs: Long?,
|
||||||
|
refreshToken: String? = REFRESH_TOKEN,
|
||||||
|
): MockHttpSession =
|
||||||
|
MockHttpSession().apply {
|
||||||
|
setAttribute(SessionKeys.TOKEN, "old-access")
|
||||||
|
setAttribute(SessionKeys.USER, USER)
|
||||||
|
if (expiresInMs != null) {
|
||||||
|
setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs)
|
||||||
|
}
|
||||||
|
if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun auth(refreshToken: String? = "new-refresh") =
|
||||||
|
AuthResponseDto(
|
||||||
|
token = "new-access",
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
accessTokenExpiresIn = 900,
|
||||||
|
id = "test-id",
|
||||||
|
login = "test-login",
|
||||||
|
role = "ADMIN",
|
||||||
|
name = "Тестовый пользователь",
|
||||||
|
mustChangePassword = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val REFRESH_TOKEN = "old-refresh"
|
||||||
|
|
||||||
|
val USER =
|
||||||
|
SessionUser(
|
||||||
|
id = "test-id",
|
||||||
|
login = "test-login",
|
||||||
|
role = "STUDENT",
|
||||||
|
name = "Тестовый пользователь",
|
||||||
|
mustChangePassword = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user