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 @@ -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)
Expand Down Expand Up @@ -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 =
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -79,28 +79,31 @@ 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())
}
}
}

@Test
fun `토큰 거절 1000과 미확인 또는 파싱 불가 401은 기존 갱신 경로를 따른다`() {
listOf(
"""{"status":401,"code":1000}""",
"""{"status":401,"code":2701}""",
"""{"status":401,"code":9999}""",
"""{"status":401}""",
"""{"code":"1202"}""",
Expand Down
Loading