Security · Backend
비밀번호 없이 로그인하기
— 다섯 개 언어 전부에 있던 취약점
보안 감사 결과 /auth/sign-in이 userId 하나만 받고 다른 건 아무것도 확인하지 않는다는 사실이 드러났다 — 비밀번호도, 해시 비교도 없이 무조건 JWT를 발급하고 있었다. 같은 버그가 다섯 언어에서 각기 다른 모습으로 나타난 과정과, 그 과정에서 새로 작성한 401 테스트가 우연히 잡아낸 재시도 버그를 소개한다.
어떤 버그는 미묘하다. 이건 그렇지 않았다: POST /auth/sign-in은 userId를 받아 그 값에 대한 유효한 액세스 토큰을 발급했다. 요청 바디 일부에는 비밀번호 필드조차 없었다. 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가 /error에 permitAll을 걸어두지 않았던 것이다. 인증이 필요 없는 /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 스위트