From 6ddb57011f2102253bd8241501998ec04024a99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Wed, 7 Oct 2026 06:05:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(core):=20=ED=8C=A8=EC=8A=A4=ED=82=A4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20401(2701)=EC=9D=84=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=84=EC=86=A1=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20(#2287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패스키 등록은 서버가 첫 요청에서 챌린지를 소비한다. 2701 을 토큰 만료로 보고 재발급 뒤 같은 본문을 다시 보내면 실제 거절 사유가 챌린지 무효(2700)로 가려진다. 1202 와 같은 업무 오류 판별 경로에 2701 을 더한다. --- .../network/interceptor/TokenAuthenticator.kt | 13 +++++--- .../interceptor/TokenAuthenticatorTest.kt | 33 ++++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt index 52b1d4258..3dd1aa79c 100644 --- a/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt +++ b/core/network/src/main/kotlin/com/afternote/core/network/interceptor/TokenAuthenticator.kt @@ -29,9 +29,10 @@ class TokenAuthenticator route: Route?, response: Response, ): Request? { - // 현재 비밀번호 불일치도 HTTP 401이다. 토큰 갱신이나 한도 초과로 세션을 지우지 않고 - // 원래 오류 본문을 Retrofit의 실패 처리에 넘긴다. - if (response.isPasswordMismatch()) return null + // 현재 비밀번호 불일치와 패스키 검증 실패도 HTTP 401이다. 토큰 갱신이나 한도 초과로 세션을 지우지 않고 + // 원래 오류 본문을 Retrofit의 실패 처리에 넘긴다. 패스키 등록은 첫 요청에서 서버가 챌린지를 + // 소비하므로, 재시도하면 실제 사유(2701)가 챌린지 무효(2700)로 바뀐다 (#2287). + if (response.isBusinessUnauthorized()) return null if (response.responseCount >= 3) { errorReporter.recordAuthContractViolation(AUTH_STAGE_RETRY_LIMIT) @@ -81,7 +82,7 @@ class TokenAuthenticator } /** 확인된 업무 오류만 제외한다. 알 수 없거나 파싱할 수 없는 401은 기존 갱신 경로를 따른다. */ -private fun Response.isPasswordMismatch(): Boolean { +private fun Response.isBusinessUnauthorized(): Boolean { val body = peekBody(MAX_ERROR_BODY_BYTES + 1) if (body.contentLength() > MAX_ERROR_BODY_BYTES) return false val parsed = @@ -93,7 +94,7 @@ private fun Response.isPasswordMismatch(): Boolean { null } val code = parsed?.get("code") as? JsonPrimitive - return code != null && !code.isString && code.intOrNull == CODE_PASSWORD_MISMATCH + return code != null && !code.isString && code.intOrNull in BUSINESS_UNAUTHORIZED_CODES } private fun ErrorReporter.recordAuthContractViolation(authStage: String) { @@ -128,4 +129,6 @@ private const val AUTH_STAGE_RETRY_LIMIT = "retry_limit" private const val AUTH_STAGE_MISSING_AUTH_HEADER = "missing_auth_header" private const val AUTH_STAGE_SAME_TOKEN = "same_token" private const val CODE_PASSWORD_MISMATCH = 1202 +private const val CODE_PASSKEY_VERIFICATION_FAILED = 2701 +private val BUSINESS_UNAUTHORIZED_CODES = setOf(CODE_PASSWORD_MISMATCH, CODE_PASSKEY_VERIFICATION_FAILED) private const val MAX_ERROR_BODY_BYTES = 64 * 1024L diff --git a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt index 95a762598..2b3c2edbe 100644 --- a/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt +++ b/core/network/src/test/kotlin/com/afternote/core/network/interceptor/TokenAuthenticatorTest.kt @@ -79,20 +79,24 @@ class TokenAuthenticatorTest { } @Test - fun `비밀번호 불일치 1202는 갱신과 세션 정리 없이 원래 본문을 남긴다`() { - listOf(0, 1, 2).forEach { priorCount -> - val reporter = FakeErrorReporter() - val repository = networkFakeAuthRepository(accessToken = "old-token") - val body = """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""" - val response = unauthorizedResponse(priorCount = priorCount, body = body) - - assertNull(authenticator(repository, reporter).authenticate(null, response)) - - assertEquals(0, repository.rotateCallCount) - assertEquals(0, repository.clearSessionCallCount) - assertEquals("old-token", repository.accessToken) - assertEquals(0, reporter.writtenFailures.size) - assertEquals(body, response.body.string()) + fun `비밀번호 불일치 1202와 패스키 검증 실패 2701은 갱신과 세션 정리 없이 원래 본문을 남긴다`() { + listOf( + """{"status":401,"code":1202,"message":"현재 비밀번호가 일치하지 않습니다."}""", + """{"status":401,"code":2701,"message":"패스키 검증에 실패했습니다."}""", + ).forEach { body -> + listOf(0, 1, 2).forEach { priorCount -> + val reporter = FakeErrorReporter() + val repository = networkFakeAuthRepository(accessToken = "old-token") + val response = unauthorizedResponse(priorCount = priorCount, body = body) + + assertNull(body, authenticator(repository, reporter).authenticate(null, response)) + + assertEquals(body, 0, repository.rotateCallCount) + assertEquals(body, 0, repository.clearSessionCallCount) + assertEquals(body, "old-token", repository.accessToken) + assertEquals(body, 0, reporter.writtenFailures.size) + assertEquals(body, response.body.string()) + } } } @@ -100,7 +104,6 @@ class TokenAuthenticatorTest { fun `토큰 거절 1000과 미확인 또는 파싱 불가 401은 기존 갱신 경로를 따른다`() { listOf( """{"status":401,"code":1000}""", - """{"status":401,"code":2701}""", """{"status":401,"code":9999}""", """{"status":401}""", """{"code":"1202"}""",