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 c0cf11a5a3
commit 51d1b84297
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()
);