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