Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,34 +16,58 @@ class RedisAuthAttemptLimiter(
private val loginIdHasher: LoginIdHasher,
@Value("\${identity.redis.namespace}") private val namespace: String,
@Value("\${auth.jwt.issuer}") private val issuer: String,
@Value("\${auth.login.max-attempts:5}") private val loginMaxAttempts: Int,
@Value("\${auth.login.window-seconds:900}") private val loginWindowSeconds: Long,
@Value("\${auth.login.max-attempts:30}") private val loginMaxAttempts: Int,
@Value("\${auth.login.window-seconds:300}") private val loginWindowSeconds: Long,
@Value("\${auth.password-reset.max-attempts:5}") private val resetMaxAttempts: Int,
@Value("\${auth.password-reset.window-seconds:900}") private val resetWindowSeconds: Long,
@Value("\${auth.login.ip-max-attempts:50}") private val loginIpMaxAttempts: Int = 50,
@Value("\${auth.login.ip-max-attempts:300}") private val loginIpMaxAttempts: Int = 300,
) : AuthAttemptLimiter {
init {
require(namespace.isNotBlank() && issuer.isNotBlank())
require(loginIpMaxAttempts > 0 && loginMaxAttempts > 0 && resetMaxAttempts > 0)
require(loginWindowSeconds > 0 && resetWindowSeconds > 0)
}

// 실패 횟수를 늘리지 않고 이미 생성된 차단만 검사한다.
override fun checkLogin(loginId: String, clientIp: String) {
require(clientIp.isNotBlank())
check("login-ip", clientIp, loginIpMaxAttempts, loginWindowSeconds)
check("login", "$clientIp\u0000$loginId", loginMaxAttempts, loginWindowSeconds)
execute(LOGIN_CHECK_SCRIPT, loginKeys(loginId, clientIp))
}

// 실패 누적과 임계 도달 시점의 차단을 Redis에서 원자적으로 기록한다.
override fun recordLoginFailure(loginId: String, clientIp: String) {
execute(
LOGIN_FAILURE_SCRIPT,
loginKeys(loginId, clientIp),
loginMaxAttempts.toString(), loginIpMaxAttempts.toString(), loginWindowSeconds.toString(),
)
}

// 동시에 생성된 차단을 재확인하고 계정별 연속 실패만 초기화한다.
override fun recordLoginSuccess(loginId: String, clientIp: String) {
execute(LOGIN_SUCCESS_SCRIPT, loginKeys(loginId, clientIp))
}

// 비밀번호 재설정은 기존 시도 횟수 제한을 유지한다.
override fun checkPasswordReset(loginId: String) {
execute(
ATTEMPT_SCRIPT,
listOf("$namespace:$issuer:identity:auth-attempt:password-reset:${loginIdHasher.hash(loginId)}"),
resetMaxAttempts.toString(), resetWindowSeconds.toString(),
)
}

override fun checkPasswordReset(loginId: String) = check("password-reset", loginId, resetMaxAttempts, resetWindowSeconds)
// 기존 전체 시도 카운터와 실패 카운터가 섞이지 않도록 키 버전을 분리한다.
private fun loginKeys(loginId: String, clientIp: String): List<String> {
require(clientIp.isNotBlank())
val pair = "$namespace:$issuer:identity:auth-attempt:login-failure-v2:${loginIdHasher.hash("$clientIp\u0000$loginId")}"
val ip = "$namespace:$issuer:identity:auth-attempt:login-ip-failure-v2:${loginIdHasher.hash(clientIp)}"
return listOf("$pair:count", "$ip:count", "$pair:lock", "$ip:lock")
}

private fun check(operation: String, loginId: String, maxAttempts: Int, windowSeconds: Long) {
// 저장소 오류와 차단 응답을 일관된 도메인 오류로 변환한다.
private fun execute(script: DefaultRedisScript<Long>, keys: List<String>, vararg args: String) {
val result = try {
redisTemplate.execute(
ATTEMPT_SCRIPT,
listOf("$namespace:$issuer:identity:auth-attempt:$operation:${loginIdHasher.hash(loginId)}"),
maxAttempts.toString(),
windowSeconds.toString(),
)
redisTemplate.execute(script, keys, *args)
} catch (exception: DataAccessException) {
throw IdentityDomainException(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, exception)
}
Expand All @@ -55,6 +79,42 @@ class RedisAuthAttemptLimiter(
}

private companion object {
val LOGIN_CHECK_SCRIPT = DefaultRedisScript(
"""
if redis.call('EXISTS', KEYS[3], KEYS[4]) > 0 then
return 0
end
return 1
""".trimIndent(),
Long::class.java,
)
val LOGIN_FAILURE_SCRIPT = DefaultRedisScript(
"""
for i = 1, 2 do
if redis.call('EXISTS', KEYS[i + 2]) == 0 then
local count = redis.call('INCR', KEYS[i])
if count >= tonumber(ARGV[i]) then
redis.call('SET', KEYS[i + 2], '1', 'EX', ARGV[3])
redis.call('DEL', KEYS[i])
else
redis.call('EXPIRE', KEYS[i], ARGV[3])
end
end
end
return 1
""".trimIndent(),
Long::class.java,
)
val LOGIN_SUCCESS_SCRIPT = DefaultRedisScript(
"""
if redis.call('EXISTS', KEYS[3], KEYS[4]) > 0 then
return 0
end
redis.call('DEL', KEYS[1])
return 1
""".trimIndent(),
Long::class.java,
)
val ATTEMPT_SCRIPT = DefaultRedisScript(
"""
local count = tonumber(redis.call('GET', KEYS[1]) or '0')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import org.mockito.Mockito.any
import org.mockito.Mockito.eq
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.Mockito.`when`
import org.springframework.dao.DataAccessResourceFailureException
import org.springframework.data.redis.core.StringRedisTemplate
Expand All @@ -18,59 +17,54 @@ import org.springframework.data.redis.core.script.RedisScript
class RedisAuthAttemptLimiterTest {
private val template = mock(StringRedisTemplate::class.java)
private val hasher = HmacLoginIdHasher("attempt-test-key")
private val limiter = RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 5, 900, 3, 60)
private val limiter = RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 30, 300, 3, 60)

@Test
fun usesSeparateHashedKeysAndConfiguredLimits() {
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("login-ip"))), eq("50"), eq("900")))
.thenReturn(1L)
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("login"))), eq("5"), eq("900")))
.thenReturn(1L)
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("password-reset"))), eq("3"), eq("60")))
.thenReturn(1L)
limiter.checkLogin("01012345678", "192.0.2.1")
limiter.checkPasswordReset("01012345678")
verify(template).execute(any<RedisScript<Long>>(), eq(listOf(key("login"))), eq("5"), eq("900"))
verify(template).execute(any<RedisScript<Long>>(), eq(listOf(key("password-reset"))), eq("3"), eq("60"))
fun failureUsesHashedScopeKeysAndConfiguredFailureLimits() {
`when`(template.execute(any<RedisScript<Long>>(), eq(loginKeys()), eq("30"), eq("300"), eq("300"))).thenReturn(1L)
limiter.recordLoginFailure("01012345678", "192.0.2.1")
verify(template).execute(any<RedisScript<Long>>(), eq(loginKeys()), eq("30"), eq("300"), eq("300"))
}

@Test
fun exhaustedLimitReturns429AndRedisFailureReturns503() {
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("login-ip"))), eq("50"), eq("900")))
.thenReturn(1L)
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("login"))), eq("5"), eq("900")))
fun loginCheckAndSuccessMapLockAndStoreFailure() {
`when`(template.execute(any<RedisScript<Long>>(), eq(loginKeys())))
.thenReturn(0L)
.thenThrow(DataAccessResourceFailureException("redis unavailable"))
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("01012345678", "192.0.2.1")
}.errorCode)
assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("01012345678", "192.0.2.1")
limiter.recordLoginSuccess("01012345678", "192.0.2.1")
}.errorCode)
}

@Test
fun passwordResetRetainsItsOwnKeysAndLimits() {
val key = "test:issuer:identity:auth-attempt:password-reset:${hasher.hash("01012345678")}"
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key)), eq("3"), eq("60"))).thenReturn(1L)
limiter.checkPasswordReset("01012345678")
verify(template).execute(any<RedisScript<Long>>(), eq(listOf(key)), eq("3"), eq("60"))
}

@Test
fun missingScriptResultFailsClosed() {
assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) {
limiter.checkPasswordReset("01012345678")
}.errorCode)
}

@Test
fun sourceLimitBlocksBeforeAccountLimit() {
`when`(template.execute(any<RedisScript<Long>>(), eq(listOf(key("login-ip"))), eq("50"), eq("900")))
.thenReturn(0L)
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
assertEquals(ErrorCode.AUTH_ATTEMPT_STORE_UNAVAILABLE, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("01012345678", "192.0.2.1")
}.errorCode)
verify(template).execute(any<RedisScript<Long>>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))
verifyNoMoreInteractions(template)
}

@Test(expected = IllegalArgumentException::class)
fun rejectsZeroAttemptLimit() {
RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 0, 900, 3, 60)
RedisAuthAttemptLimiter(template, hasher, "test", "issuer", 0, 300, 3, 60)
}

private fun key(operation: String) = "test:issuer:identity:auth-attempt:$operation:${hasher.hash(when (operation) { "login-ip" -> "192.0.2.1"; "login" -> "192.0.2.1\u000001012345678"; else -> "01012345678" })}"
private fun loginKeys(): List<String> {
val pair = "test:issuer:identity:auth-attempt:login-failure-v2:${hasher.hash("192.0.2.1\u000001012345678")}"
val ip = "test:issuer:identity:auth-attempt:login-ip-failure-v2:${hasher.hash("192.0.2.1")}"
return listOf("$pair:count", "$ip:count", "$pair:lock", "$ip:lock")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ class RedisRefreshTokenRotationAdapterIntegrationTest {
val first = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 2, 1, 1, 30)
val second = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 2, 1, 1, 30)
first.checkLogin("known", "192.0.2.1")
first.recordLoginFailure("known", "192.0.2.1")
second.checkLogin("known", "192.0.2.1")
second.recordLoginFailure("known", "192.0.2.1")
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
first.checkLogin("known", "192.0.2.1")
}.errorCode)
Expand All @@ -53,31 +55,96 @@ class RedisRefreshTokenRotationAdapterIntegrationTest {
}

@Test
fun concurrentLoginAttemptsDoNotExceedTheLimit() {
fun concurrentLoginFailuresSetOneLockWithoutContinuingToIncrement() {
val namespace = "attempt-${UUID.randomUUID()}"
val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"),
"attempt-${UUID.randomUUID()}", ISSUER, 5, 30, 5, 30)
namespace, ISSUER, 5, 30, 5, 30)
val executor = Executors.newFixedThreadPool(16)
val start = CountDownLatch(1)
try {
val results = (1..16).map {
executor.submit<Boolean> {
assertTrue(start.await(5, TimeUnit.SECONDS))
try {
limiter.checkLogin("known", "192.0.2.1")
true
} catch (exception: IdentityDomainException) {
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, exception.errorCode)
false
}
limiter.recordLoginFailure("known", "192.0.2.1")
true
}
}
start.countDown()
assertEquals(5, results.count { it.get(10, TimeUnit.SECONDS) })
assertEquals(16, results.count { it.get(10, TimeUnit.SECONDS) })
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("known", "192.0.2.1")
}.errorCode)
val keys = template.keys("$namespace:*")
assertEquals(1, keys.count { it.endsWith(":lock") })
assertFalse(keys.any { it.contains(":login-failure-v2:") && it.endsWith(":count") })
} finally {
executor.shutdownNow()
}
}

@Test
fun successesDoNotCountAndClearOnlyPairFailures() {
val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"),
"attempt-${UUID.randomUUID()}", ISSUER, 2, 300, 5, 30, 3)
repeat(50) {
limiter.checkLogin("known", "192.0.2.1")
limiter.recordLoginSuccess("known", "192.0.2.1")
}
limiter.recordLoginFailure("known", "192.0.2.1")
limiter.recordLoginSuccess("known", "192.0.2.1")
limiter.recordLoginFailure("known", "192.0.2.1")
limiter.checkLogin("known", "192.0.2.1")
limiter.recordLoginFailure("other", "192.0.2.1")
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("unrelated", "192.0.2.1")
}.errorCode)
limiter.checkLogin("known", "192.0.2.2")
}

@Test
fun productionThresholdsAllowThirtyPairAndThreeHundredSourceFailures() {
val limiter = RedisAuthAttemptLimiter(template, HmacLoginIdHasher("attempt-test-key"),
"attempt-${UUID.randomUUID()}", ISSUER, 30, 300, 5, 30, 300)
repeat(30) {
limiter.checkLogin("known", "192.0.2.1")
limiter.recordLoginFailure("known", "192.0.2.1")
}
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("known", "192.0.2.1")
}.errorCode)
repeat(270) { index ->
limiter.checkLogin("other-$index", "192.0.2.1")
limiter.recordLoginFailure("other-$index", "192.0.2.1")
}
assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) {
limiter.checkLogin("another", "192.0.2.1")
}.errorCode)
}

@Test
fun failureCounterRefreshesButLockTtlStartsAtThresholdAndNeverRefreshes() {
val namespace = "attempt-${UUID.randomUUID()}"
val hasher = HmacLoginIdHasher("attempt-test-key")
val limiter = RedisAuthAttemptLimiter(template, hasher, namespace, ISSUER, 3, 300, 5, 30)
val pairPrefix = "$namespace:$ISSUER:identity:auth-attempt:login-failure-v2:${hasher.hash("192.0.2.1\u0000known")}:"
limiter.recordLoginFailure("known", "192.0.2.1")
template.expire(pairPrefix + "count", 30, TimeUnit.SECONDS)
limiter.recordLoginFailure("other", "192.0.2.1")
// 다른 계정 실패는 pair의 유효기간을 갱신하지 않는다.
assertTrue(template.getExpire(pairPrefix + "count") <= 30)
limiter.recordLoginFailure("known", "192.0.2.1")
assertTrue(template.getExpire(pairPrefix + "count") in 298L..300L)
limiter.recordLoginFailure("known", "192.0.2.1")
assertTrue(template.getExpire(pairPrefix + "lock") in 298L..300L)
template.expire(pairPrefix + "lock", 2, TimeUnit.SECONDS)
limiter.recordLoginFailure("known", "192.0.2.1")
assertThrows(IdentityDomainException::class.java) { limiter.recordLoginSuccess("known", "192.0.2.1") }
assertTrue(template.getExpire(pairPrefix + "lock") <= 2)
Thread.sleep(2100)
limiter.checkLogin("known", "192.0.2.1")
limiter.recordLoginSuccess("known", "192.0.2.1")
}

@Before
fun clearState() {
template.keys("$KEY_PREFIX*").forEach(template::delete)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package hs.kr.entrydsm.identity.application.port.out

/** 로그인과 비밀번호 재설정의 시도 횟수를 각각 제한한다. */
/** 로그인 실패와 비밀번호 재설정 시도를 각각 제한한다. */
interface AuthAttemptLimiter {
/** 로그인 전에 기존 차단 여부만 확인한다. */
fun checkLogin(loginId: String, clientIp: String)
/** 자격 증명 불일치만 실패 횟수에 반영한다. */
fun recordLoginFailure(loginId: String, clientIp: String)
/** 차단 여부를 재확인하고 해당 IP와 계정의 연속 실패를 초기화한다. */
fun recordLoginSuccess(loginId: String, clientIp: String)
fun checkPasswordReset(loginId: String)
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,20 @@ class AuthService(
return savedAccount.toAccountResult()
}

// 자격 증명 실패만 누적하고 차단 확인 후 성공 상태를 반영한다.
override fun login(command: LoginCommand): AuthTokenResult {
requireValidPassword(command.password)
if (command.loginId.isBlank()) throw IdentityDomainException(ErrorCode.INVALID_REQUEST_BODY)
authAttemptLimiter.checkLogin(command.loginId, command.clientIp)
val account = accountQueryPort.findByLoginId(command.loginId)
?: throw IdentityDomainException(ErrorCode.INVALID_CREDENTIALS)
if (!passwordHasher.matches(command.password, account.passwordHash)) {
if (account == null || !passwordHasher.matches(command.password, account.passwordHash)) {
authAttemptLimiter.recordLoginFailure(command.loginId, command.clientIp)
throw IdentityDomainException(ErrorCode.INVALID_CREDENTIALS)
}
if (account.status != AccountStatus.ACTIVE) {
throw IdentityDomainException(ErrorCode.ACCOUNT_INACTIVE)
}
authAttemptLimiter.recordLoginSuccess(command.loginId, command.clientIp)
return issueTokens(account.userId, account.role, account.status, account.tokenVersion)
}

Expand Down
Loading
Loading