Redesign B3 bordeaux + finish lab-page rework

- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
This commit is contained in:
2026-10-02 15:41:16 +03:00
parent 7a781fea33
commit 556fdf3b5e
67 changed files with 3019 additions and 653 deletions

View File

@@ -27,6 +27,7 @@ class AuthService(
private val passwordEncoder: PasswordEncoder,
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
private val backoff: LoginBackoff,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@@ -51,14 +52,16 @@ class AuthService(
return toResponse(account, account.login)
}
@Transactional(readOnly = true)
/** Без @Transactional на методе: задержка backoff не должна держать JDBC-соединение. */
fun login(request: LoginRequest): AuthResponse {
val account =
accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
val login = request.login.trim()
backoff.delayBeforeAttempt(login)
val account = accounts.findByLogin(login)
if (account == null || !passwordEncoder.matches(request.password, account.passwordHash)) {
backoff.recordFailure(login)
throw UnauthorizedException("Неверный логин или пароль")
}
backoff.reset(login)
return toResponse(account, displayName(account))
}

View File

@@ -0,0 +1,74 @@
package ru.ysu.lms.identity.auth
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.TooManyRequestsException
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import java.time.Duration
import java.time.Instant
import kotlin.math.min
import kotlin.math.pow
/**
* Экспоненциальная задержка между неудачными попытками входа (1с, 2с, 4с…).
* Задержка растёт по числу неудач, а не по времени ожидания ответа, поэтому
* перебор пароля становится бессмысленно долгим, а легитимный пользователь,
* знающий пароль, блокировок не видит. Счётчик живёт в БД (переживает рестарт)
* и обнуляется по успеху либо по истечении окна [WINDOW].
*/
@Service
class LoginBackoff(
private val attempts: LoginAttemptRepository,
) {
/** Короткие задержки выдерживаем в потоке запроса; длиннее — сразу 429 с паузой для клиента. */
fun delayBeforeAttempt(login: String) {
val failures = effectiveFailures(login)
if (failures <= 0) return
val delaySec = delaySecondsFor(failures)
if (delaySec > INLINE_SLEEP_SECONDS) {
throw TooManyRequestsException(
"Слишком много неудачных попыток входа. Повторите через $delaySec с.",
)
}
Thread.sleep(delaySec * 1000)
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun recordFailure(login: String) {
val now = Instant.now()
val row = attempts.findById(login).orElse(null)
if (row == null) {
attempts.save(LoginAttempt(login = login, failures = 1, lastFailureAt = now))
} else {
row.failures += 1
row.lastFailureAt = now
attempts.save(row)
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun reset(login: String) {
attempts.findById(login).orElse(null)?.let(attempts::delete)
}
/** Неудачи внутри окна [WINDOW]; устаревшую строку сразу гасим. */
private fun effectiveFailures(login: String): Int {
val row = attempts.findById(login).orElse(null) ?: return 0
if (row.lastFailureAt.isBefore(Instant.now().minus(WINDOW))) {
attempts.delete(row)
return 0
}
return row.failures
}
companion object {
private val WINDOW: Duration = Duration.ofMinutes(15)
private const val MAX_DELAY_SECONDS = 60L
private const val INLINE_SLEEP_SECONDS = 5L
/** 1, 2, 4, 8 … секунд, с потолком [MAX_DELAY_SECONDS]. */
fun delaySecondsFor(failures: Int): Long = min(2.0.pow((failures - 1).coerceAtMost(6)), MAX_DELAY_SECONDS.toDouble()).toLong()
}
}

View File

@@ -0,0 +1,20 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
/** Счётчик неудачных попыток входа для конкретного логина (см. LoginBackoff). */
@Entity
@Table(name = "login_attempts")
class LoginAttempt(
@Id
@Column(nullable = false, length = 64)
var login: String = "",
@Column(nullable = false)
var failures: Int = 0,
@Column(name = "last_failure_at", nullable = false)
var lastFailureAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.LoginAttempt
interface LoginAttemptRepository : JpaRepository<LoginAttempt, String>

View File

@@ -0,0 +1,7 @@
-- Счётчик неудачных входов для экспоненциальной задержки (защита от перебора паролей).
CREATE TABLE login_attempts (
login varchar(64) PRIMARY KEY,
failures integer NOT NULL DEFAULT 0,
last_failure_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,142 @@
package ru.ysu.lms.identity
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
import ru.ysu.lms.common.TooManyRequestsException
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.domain.Account
import ru.ysu.lms.identity.domain.LoginAttempt
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.LoginAttemptRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Duration
import java.time.Instant
import java.util.Optional
class AuthServiceLoginBackoffTest {
private val login = "2025001"
private val account = Account(login = login, passwordHash = "hash", role = LmsRole.STUDENT)
/** Не relaxed: дженерик save у JpaRepository в relaxed-режиме ломает тип возвращаемого значения. */
private fun attemptsRepo(): LoginAttemptRepository {
val repo = mockk<LoginAttemptRepository>()
every { repo.save(any<LoginAttempt>()) } answers { firstArg() }
every { repo.delete(any<LoginAttempt>()) } just Runs
return repo
}
private fun service(
accounts: AccountRepository,
attempts: LoginAttemptRepository,
matches: Boolean,
): AuthService {
val students = mockk<StudentRepository>()
every { students.findById(account.id) } returns Optional.empty()
val passwordEncoder = mockk<PasswordEncoder>()
every { passwordEncoder.matches(any(), any()) } returns matches
val jwtEncoder = mockk<JwtEncoder>()
every { jwtEncoder.encode(any<JwtEncoderParameters>()) } returns
mockk<Jwt> { every { tokenValue } returns "token" }
val backoff = LoginBackoff(attempts)
return AuthService(
accounts,
students,
passwordEncoder,
jwtEncoder,
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
backoff,
)
}
@Test
fun `failed login records failure`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returns Optional.empty()
val svc = service(accounts, attempts, matches = false)
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
.isInstanceOf(UnauthorizedException::class.java)
verify {
attempts.save(
match {
it.login == login &&
it.failures == 1 &&
it.lastFailureAt.isAfter(Instant.now().minusSeconds(5))
},
)
}
}
@Test
fun `many failures rejected with 429 before password check`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
every { attempts.findById(login) } returns
Optional.of(LoginAttempt(login = login, failures = 6, lastFailureAt = Instant.now()))
val svc = service(accounts, attempts, matches = true)
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "secret")) }
.isInstanceOf(TooManyRequestsException::class.java)
.hasMessageContaining("32")
verify(exactly = 0) { accounts.findByLogin(any()) }
}
@Test
fun `successful login resets counter`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
val stale = LoginAttempt(login = login, failures = 2, lastFailureAt = Instant.now())
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returnsMany
listOf(Optional.empty(), Optional.of(stale))
val svc = service(accounts, attempts, matches = true)
val response = svc.login(LoginRequest(login = login, password = "secret"))
assertThat(response.token).isEqualTo("token")
verify { attempts.delete(stale) }
}
@Test
fun `backoff grows exponentially and caps at 60s`() {
assertThat(LoginBackoff.delaySecondsFor(1)).isEqualTo(1)
assertThat(LoginBackoff.delaySecondsFor(2)).isEqualTo(2)
assertThat(LoginBackoff.delaySecondsFor(3)).isEqualTo(4)
assertThat(LoginBackoff.delaySecondsFor(4)).isEqualTo(8)
assertThat(LoginBackoff.delaySecondsFor(7)).isEqualTo(60)
assertThat(LoginBackoff.delaySecondsFor(50)).isEqualTo(60)
}
@Test
fun `stale failures outside window are ignored and cleared`() {
val attempts = attemptsRepo()
val accounts = mockk<AccountRepository>()
val stale = LoginAttempt(login = login, failures = 9, lastFailureAt = Instant.now().minusSeconds(16 * 60))
every { accounts.findByLogin(login) } returns account
every { attempts.findById(login) } returns Optional.of(stale)
val svc = service(accounts, attempts, matches = false)
// failures=9 должны были дать 429; из-за устаревания строки — обычная неудача входа.
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
.isInstanceOf(UnauthorizedException::class.java)
verify { attempts.delete(stale) }
}
}