Security · Backend
비밀번호 없이 로그인하기
설계와 함께 옮겨 간 취약점
설계 하나를 여러 언어로 옮기면 처음 구현의 보안 구멍도 같이 옮겨 간다. 보안 감사를 해 보니 /auth/sign-in은 어느 구현에서나 userId 하나만 받고 아무것도 확인하지 않았다. 비밀번호도 해시 비교도 없이 JWT를 그냥 내주고 있었다. 같은 버그가 5개 언어에서 저마다 어떤 모양이었는지, 무엇으로 막았는지, 그리고 이걸 고치며 새로 쓴 401 테스트가 덤으로 찾아낸 재시도 버그 이야기를 적는다.
찾기 어려운 버그도 있지만 이건 아니었다. POST /auth/sign-in은 userId만 받으면 그 사용자의 액세스 토큰을 발급했다. 몇몇 언어는 요청 바디에 비밀번호 필드부터 없었다. Credential 저장소도 비밀번호 해시도 없으니 비교할 대상이 애초에 없었다. 남의 ID를 알거나 맞히기만 하면 그 사람으로 로그인할 수 있었다.
로직 구석의 예외 상황 정도가 아니었다. 인증을 통째로 건너뛸 수 있는 구멍이었다. 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서, 이 구멍은 5개 구현 모두에 있었다. 처음 구현을 다른 언어로 옮길 때 구멍도 같이 옮겨 갔다.
같은 버그, 다섯 가지 다른 모습
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을 던진다. 5개 언어 모두 그렇다. "그런 사용자 없음"과 "비밀번호 틀림"을 다른 에러로 돌려주면, 로그인 폼이 어떤 ID가 가입돼 있는지 알려 주는 창구가 된다. 작은 부분이지만 언어별 구현이 각자 판단하게 두면 안 되고, 컨벤션으로 정해 둬야 한다.
테스트로 붙잡아 두기
언어마다 E2E 스위트를 새로 넣었다. 자격 증명이 틀리면 401이 오는지 확인하는 테스트로, 그전에는 어느 언어에도 없었다.
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을 쓴다. 이 클래스에는 문서에도 나와 있는 특이한 동작이 있어서, POST 요청이 401을 받으면 응답을 돌려주지 않고 IOException: cannot retry due to server authentication, in streaming mode를 던진다.
테스트는 맞게 짰다. 그 밑에 깔린 기본 HTTP 클라이언트가 401 응답을 받아 내지 못했던 것이다.
요청 팩토리를 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은 비밀번호 해시를 검증해야 한다"를 기계적으로 보는 규칙은 거기에 없다. 비즈니스 로직에 관한 주장이라서, 그 검사는 일부러 손대지 않는 영역이다.
회귀를 막는 건 언어마다 하나씩 있는 E2E 스위트다. 없는 사용자와 틀린 비밀번호 두 경우 모두 401/INVALID_CREDENTIALS가 오는지 보고, sign-up 검증 케이스도 함께 돈다. 계속 남아 있는 안전망이긴 한데, 구조로 강제하는 안전망은 아니다.
docs/architecture/authentication.md(같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트 backend-service-playbook의 JWT 발급과 자격 증명 검증 흐름 전체) · AuthControllerE2ETest.java(401 재시도 버그 수정까지 들어간 Java E2E 스위트)