Security · Backend

비밀번호 없이 로그인하기
— 다섯 개 언어 전부에 있던 취약점

보안 감사 결과 /auth/sign-in이 userId 하나만 받고 다른 건 아무것도 확인하지 않는다는 사실이 드러났다 — 비밀번호도, 해시 비교도 없이 무조건 JWT를 발급하고 있었다. 같은 버그가 다섯 언어에서 각기 다른 모습으로 나타난 과정과, 그 과정에서 새로 작성한 401 테스트가 우연히 잡아낸 재시도 버그를 소개한다.

어떤 버그는 미묘하다. 이건 그렇지 않았다: POST /auth/sign-inuserId를 받아 그 값에 대한 유효한 액세스 토큰을 발급했다. 요청 바디 일부에는 비밀번호 필드조차 없었다. Credential 저장소도, 비밀번호 해시도, 비교할 대상 자체가 없었다. 다른 사용자의 ID를 알거나 추측하기만 하면 누구든 그 사용자로 로그인할 수 있었다. 사소한 로직 예외가 아니라 완전한 인증 우회였고, 이 저장소의 다섯 언어 구현 전부에 동일하게 존재했다 — 모두 같은 원본의 결함을 그대로 이식했기 때문이다.

같은 버그, 다섯 가지 다른 모습

Kotlin에서는 sign-in 경로 전체가 이 정도로 짧았다:

data class SignInRequest(@field:NotBlank val userId: String)
data class SignInResponse(val accessToken: String)

@RestController
@RequestMapping("/auth")
class AuthController(private val authService: AuthService) {
    @PostMapping("/sign-in")
    fun signIn(@Valid @RequestBody request: SignInRequest): SignInResponse =
        SignInResponse(authService.sign(request.userId))
}

요청 DTO에 비밀번호 필드 자체가 아예 없었다. Go의 버전도 마찬가지였다 — userId 필드 하나뿐, 대조할 게 아무것도 없었다:

type SignInRequest struct {
	UserID string `json:"userId"`
}

func (h *AuthHandler) SignIn(w http.ResponseWriter, r *http.Request) {
	var body SignInRequest
	json.NewDecoder(r.Body).Decode(&body)
	accessToken, _ := h.jwtService.Sign(body.UserID)
	// ...
}

Java 구현은 스스로 이 문제를 가장 잘 인지하고 있었다 — 원본 서비스의 주석이 그 결함을 대놓고 인정하고 있었다: "별도의 credential 저장소가 존재하지 않음 — 어떤 credential 검사도 없이 주어진 userId에 대한 토큰을 발급함."

수정

수정의 형태는 모든 언어에서 동일하다: 비밀번호 해시를 갖는 진짜 Credential Aggregate, PasswordHasher Technical Service(bcrypt, strength 12), 그리고 토큰을 발급하기 전에 조회 후 검증하는 순서:

public SignInResult signIn(SignInCommand command) {
    Credential credential = credentialQuery
            .findCredentials(new CredentialFindQuery(0, 1, command.userId()))
            .credentials().stream().findFirst()
            .orElseThrow(() -> new AuthException(
                    AuthException.ErrorCode.INVALID_CREDENTIALS, "Invalid ID or password."));

    if (!passwordHasher.verify(command.password(), credential.getPasswordHash())) {
        throw new AuthException(
                AuthException.ErrorCode.INVALID_CREDENTIALS, "Invalid ID or password.");
    }
    // ...only then does JWT issuance happen
}
같은 에러, 의도적으로

존재하지 않는 사용자와 잘못된 비밀번호 모두 동일하게 INVALID_CREDENTIALS/401을 던진다는 점에 주목하자 — 다섯 언어 전부에서 그대로 반복된다. "그런 사용자가 없음"과 "비밀번호가 틀림"에 서로 다른 에러를 반환하면 로그인 폼이 사용자 존재 여부를 알려주는 오라클이 되어버린다. 사소해 보이지만, 각 언어 포트가 알아서 판단하게 둘 게 아니라 의도적인 컨벤션으로 못 박아야 하는 종류의 디테일이다.

실제로 이걸 잡아낸 과정

모든 언어가 잘못된 자격 증명이면 401을 반환하는지 검증하는 완전히 새로운 E2E 스위트를 추가했다 — 이전에는 어느 언어에도 없던 테스트다:

it('returns 401 with INVALID_CREDENTIALS when the password is wrong', async () => {
  await request(app.getHttpServer())
    .post('/auth/sign-up')
    .send({ userId: 'owner-2', password: 'password123!' })
    .expect(201)

  const response = await request(app.getHttpServer())
    .post('/auth/sign-in')
    .send({ userId: 'owner-2', password: 'wrong-password' })
    .expect(401)

  expect(response.body).toMatchObject({ code: 'INVALID_CREDENTIALS' })
})

버그 수정이 찾아낸 또 다른 버그

java-springboot든 kotlin-springboot든, 이 테스트는 두 포트에서 실제 401 응답을 검증한 최초의 테스트였다. 그리고 이게 중요한 문제였음이 드러났다: Spring의 기본 TestRestTemplate 요청 팩토리는 JDK 자체의 HttpURLConnection 위에 구축되어 있는데, 여기에 문서화된 특이 동작이 하나 있다 — 401을 응답으로 받는 POST 요청이 그냥 응답을 반환하는 대신 IOException: cannot retry due to server authentication, in streaming mode를 던진다는 것이다. 테스트 자체는 정확했다. 그 아래에 깔린 기본 HTTP 클라이언트가 자기 자신의 결과조차 제대로 처리하지 못했을 뿐이다.

수정은 요청 팩토리를 Apache의 httpclient5 기반 팩토리로 교체하는 것이었다. 이쪽은 이런 한계가 없다:

// TestRestTemplate's default request factory (JDK HttpURLConnection-based) has a known
// limitation: it throws "cannot retry due to server authentication, in streaming mode" when
// a POST gets a 401 back. This test asserts a real sign-in failure (401), so swap in the
// httpclient5-based factory instead (see build.gradle's testImplementation httpclient5).
@BeforeEach
void useApacheHttpClientRequestFactory() {
    restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory())
}

두 번째 버그, 같은 파급 범위

Java 쪽 수정은 인증 로직과는 전혀 무관한 문제도 하나 더 잡아냈다: SecurityConfig/errorpermitAll을 걸어두지 않았던 것이다. 인증이 필요 없는 /auth/sign-up 엔드포인트에 잘못된 요청이 들어와 Bean Validation이 이를 거부하면, 서블릿 컨테이너가 내부적으로 /error로 재전달(redispatch)하는 과정이 Spring Security 필터 체인을 다시 거치게 되었고 — 원래 의도했던 400이 대신 401로 돌아왔다. 새로 추가한 비밀번호 길이 검증 테스트가 이를 잡아냈다. 수정은 단 한 줄, 기존 인증 엔드포인트들 옆의 permit 목록에 /error를 추가하는 것이었다.

지금 실제로 이걸 지켜주는 것

"고쳤다"는 게 정확히 무엇을 의미하는지는 짚고 넘어갈 가치가 있다: "sign-in은 반드시 비밀번호 해시를 검증해야 한다"를 기계적으로 검사하는 전용 Harness 규칙은 없다 — 이는 비즈니스 로직에 대한 단언이고, 이 저장소의 Harness들은 의도적으로 비즈니스 로직에는 관여하지 않는다. 실제로 회귀를 막아주는 것은 E2E 스위트 그 자체다 — 언어마다 하나씩, 존재하지 않는 사용자와 잘못된 비밀번호 양쪽 모두에서 401/INVALID_CREDENTIALS가 반환되는지 검증하고, 여기에 sign-up 검증 케이스들이 더해진다. 이것은 실질적이고 영구적인 안전망이다 — 다만 구조적인 안전망은 아닐 뿐이다.

저장소 내 추가 자료

docs/architecture/authentication.md — 전체 JWT/자격증명 검증 흐름 · AuthControllerE2ETest.java — 401/재시도 버그 수정을 포함한 실제 E2E 스위트