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:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
);
|
||||
Reference in New Issue
Block a user