diff --git a/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt b/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt index d4166d2b..0c622725 100644 --- a/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt +++ b/systems/identity/identity-adapter-out/src/main/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiter.kt @@ -16,11 +16,11 @@ 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()) @@ -28,22 +28,46 @@ class RedisAuthAttemptLimiter( 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 { + 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, keys: List, 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) } @@ -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') diff --git a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt index 50da0395..46df1937 100644 --- a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt +++ b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisAuthAttemptLimiterTest.kt @@ -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 @@ -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>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), eq(listOf(key("login"))), eq("5"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), 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>(), eq(listOf(key("login"))), eq("5"), eq("900")) - verify(template).execute(any>(), eq(listOf(key("password-reset"))), eq("3"), eq("60")) + fun failureUsesHashedScopeKeysAndConfiguredFailureLimits() { + `when`(template.execute(any>(), eq(loginKeys()), eq("30"), eq("300"), eq("300"))).thenReturn(1L) + limiter.recordLoginFailure("01012345678", "192.0.2.1") + verify(template).execute(any>(), eq(loginKeys()), eq("30"), eq("300"), eq("300")) } @Test - fun exhaustedLimitReturns429AndRedisFailureReturns503() { - `when`(template.execute(any>(), eq(listOf(key("login-ip"))), eq("50"), eq("900"))) - .thenReturn(1L) - `when`(template.execute(any>(), eq(listOf(key("login"))), eq("5"), eq("900"))) + fun loginCheckAndSuccessMapLockAndStoreFailure() { + `when`(template.execute(any>(), 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>(), eq(listOf(key)), eq("3"), eq("60"))).thenReturn(1L) + limiter.checkPasswordReset("01012345678") + verify(template).execute(any>(), 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>(), 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>(), 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 { + 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") + } } diff --git a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt index d05de4ec..7a2c8e6d 100644 --- a/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt +++ b/systems/identity/identity-adapter-out/src/test/kotlin/hs/kr/entrydsm/identity/adapterout/security/RedisRefreshTokenRotationAdapterIntegrationTest.kt @@ -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) @@ -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 { 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) diff --git a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt index 323dea97..4330c2f8 100644 --- a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt +++ b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/port/out/AuthAttemptLimiter.kt @@ -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) } diff --git a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt index 4cd0a9f3..d310c626 100644 --- a/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt +++ b/systems/identity/identity-application/src/main/kotlin/hs/kr/entrydsm/identity/application/service/AuthService.kt @@ -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) } diff --git a/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt b/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt index c5529ae9..c395b960 100644 --- a/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt +++ b/systems/identity/identity-application/src/test/kotlin/hs/kr/entrydsm/identity/application/service/AuthServiceTest.kt @@ -448,6 +448,60 @@ class AuthServiceTest { assertEquals(emptyMap(), refreshTokenVersions) } + @Test + fun successfulLoginClearsPairFailuresWithoutRecordingFailure() { + val limiter = mock(AuthAttemptLimiter::class.java) + `when`(queryPort.findByLoginId("entry")).thenReturn(account()) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + service(limiter = limiter).login(LoginCommand("entry", "password123!")) + val order = org.mockito.Mockito.inOrder(limiter, queryPort, passwordHasher) + order.verify(limiter).checkLogin("entry", "unknown") + order.verify(queryPort).findByLoginId("entry") + order.verify(passwordHasher).matches("password123!", PASSWORD_HASH) + order.verify(limiter).recordLoginSuccess("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginFailure("entry", "unknown") + } + + @Test + fun unknownAccountAndWrongPasswordBothRecordInvalidCredentialFailures() { + val limiter = mock(AuthAttemptLimiter::class.java) + val auth = service(limiter = limiter) + `when`(queryPort.findByLoginId("entry")).thenReturn(null).thenReturn(account()) + repeat(2) { + val exception = assertThrows(IdentityDomainException::class.java) { + auth.login(LoginCommand("entry", "wrong-password")) + } + assertEquals(ErrorCode.INVALID_CREDENTIALS, exception.errorCode) + } + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.times(2)).recordLoginFailure("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginSuccess("entry", "unknown") + } + + @Test + fun inactiveAndMalformedLoginDoNotRecordCredentialFailure() { + val limiter = mock(AuthAttemptLimiter::class.java) + val auth = service(limiter = limiter) + assertThrows(IdentityDomainException::class.java) { auth.login(LoginCommand("", "password123!")) } + `when`(queryPort.findByLoginId("entry")).thenReturn(account(AccountStatus.INACTIVE)) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + assertEquals(ErrorCode.ACCOUNT_INACTIVE, assertThrows(IdentityDomainException::class.java) { + auth.login(LoginCommand("entry", "password123!")) + }.errorCode) + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginFailure("entry", "unknown") + org.mockito.Mockito.verify(limiter, org.mockito.Mockito.never()).recordLoginSuccess("entry", "unknown") + } + + @Test + fun concurrentLockDetectedAtSuccessfulPasswordCheckStillRejectsLogin() { + val limiter = mock(AuthAttemptLimiter::class.java) + `when`(queryPort.findByLoginId("entry")).thenReturn(account()) + `when`(passwordHasher.matches("password123!", PASSWORD_HASH)).thenReturn(true) + doThrow(IdentityDomainException(ErrorCode.AUTH_ATTEMPTS_EXCEEDED)).`when`(limiter).recordLoginSuccess("entry", "unknown") + assertEquals(ErrorCode.AUTH_ATTEMPTS_EXCEEDED, assertThrows(IdentityDomainException::class.java) { + service(limiter = limiter).login(LoginCommand("entry", "password123!")) + }.errorCode) + } + private fun service( registration: AccountRegistrationPort = AccountRegistrationPort { _, _ -> account() }, limiter: AuthAttemptLimiter = mock(AuthAttemptLimiter::class.java), diff --git a/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml b/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml index e8f6163b..ac4d8d2a 100644 --- a/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml +++ b/systems/identity/identity-bootstrap/src/main/resources/application-dev.yaml @@ -35,9 +35,9 @@ auth: password: bcrypt-strength: ${BCRYPT_STRENGTH:12} login: - ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:50} - max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:5} - window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:900} + ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:300} + max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:30} + window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:300} password-reset: enabled: ${AUTH_PASSWORD_RESET_ENABLED:false} max-attempts: ${AUTH_PASSWORD_RESET_MAX_ATTEMPTS:5} diff --git a/systems/identity/identity-bootstrap/src/main/resources/application.yaml b/systems/identity/identity-bootstrap/src/main/resources/application.yaml index 82e2114b..2ae2cdee 100644 --- a/systems/identity/identity-bootstrap/src/main/resources/application.yaml +++ b/systems/identity/identity-bootstrap/src/main/resources/application.yaml @@ -42,9 +42,9 @@ auth: password: bcrypt-strength: ${BCRYPT_STRENGTH} login: - ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:50} - max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:5} - window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:900} + ip-max-attempts: ${AUTH_LOGIN_IP_MAX_ATTEMPTS:300} + max-attempts: ${AUTH_LOGIN_MAX_ATTEMPTS:30} + window-seconds: ${AUTH_LOGIN_WINDOW_SECONDS:300} password-reset: enabled: ${AUTH_PASSWORD_RESET_ENABLED} max-attempts: ${AUTH_PASSWORD_RESET_MAX_ATTEMPTS} diff --git a/systems/identity/identity-bootstrap/src/test/resources/application-test.yml b/systems/identity/identity-bootstrap/src/test/resources/application-test.yml index 04599188..cae7b7d4 100644 --- a/systems/identity/identity-bootstrap/src/test/resources/application-test.yml +++ b/systems/identity/identity-bootstrap/src/test/resources/application-test.yml @@ -32,9 +32,9 @@ auth: password: bcrypt-strength: 4 login: - ip-max-attempts: 50 - max-attempts: 5 - window-seconds: 900 + ip-max-attempts: 300 + max-attempts: 30 + window-seconds: 300 password-reset: enabled: false max-attempts: 5