← 개발 로그 목록

website: dbclient 토큰 평문 노출 차단 + 감사로그 + 로그인 잠금 완화 + 전화번호 정규화

/ 6분 분량 / 개발 로그

장찬욱-김우진 간 "dbclient SQL 리드 접근이 안전한가" 논의에서 실제 코드를 대조하다가 반례를 찾았고, 그 후속 조치로 나온 PR이다.

dbclient는 SELECT를 자유롭게 쓸 수 있는 계정이다. 그 전제로 SQL을 읽어봤더니 password_reset_tokens와 admin_invitations에 평문 token 컬럼이 그대로 있었다. refresh_tokens는 이미 token_hash(SHA-256) 패턴을 쓰고 있는데 이 두 테이블만 예외였던 셈이다. dbclient로 SQL을 읽는 것만으로 살아있는 비밀번호 재설정 링크나 관리자 초대 링크를 그대로 재생할 수 있는 상태였다는 뜻이라, 이건 바로 막아야 했다.

해시 전환에서 문제는 기존 행이었다. 해시는 원문이 있어야 계산할 수 있는데, DB에 남은 건 이미 평문 그 자체라 "이 평문을 해시해서 옮긴다"는 마이그레이션 자체가 불가능하다. 그래서 기존 행은 버리기로 했다. 대신 이게 실제로 안전한 선택인지 TTL을 봤다. password_reset_tokens는 TTL 30분이라 배포 시점이면 거의 다 소진돼 있을 거고, admin_invitations는 TTL 72시간이라 대기 중인 게 있을 수 있는데, 다행히 AdminInvitationService.invite()가 이미 "대기 중인 초대가 있으면 취소 후 재발급"을 멱등하게 처리하고 있어서 재발송만 하면 됐다. 실제로 stage에 대기 중인 초대 1건이 있었고, PR 설명에 배포 후 재초대가 필요하다고 명시해뒀다.

마이그레이션은 SQLite 특성상 컬럼 삭제/타입 변경이 안 돼서 새 테이블을 만들고 옮기고 갈아끼우는 방식으로 짰다.

create table password_reset_tokens_new (
    used boolean not null,
    admin_id bigint not null,
    id integer,
    created_at varchar(255) not null,
    expires_at varchar(255) not null,
    token_hash varchar(255) not null unique,
    primary key (id)
);
drop table password_reset_tokens;
alter table password_reset_tokens_new rename to password_reset_tokens;

이 마이그레이션이 "기존 행을 버린다"를 실제로 지키는지 확인할 방법이 필요했다. MigrationUpgradeHarness로 마이그레이션 직전 상태를 재현해서 평문 토큰 행을 심어두고, 마이그레이션을 끝까지 돌린 다음 그 행이 정말 사라졌는지, token 컬럼 자체가 없어졌는지, token_hash 컬럼으로 조회가 되는지를 검증하는 테스트를 새로 만들었다. 같은 김에 magic_link_tokens도 같이 정리했다. 이 기능은 이미 7월에 코드까지 삭제됐는데, 그때는 Flyway 도입 전이라 ddl-auto: update로 관리되던 시절이라 DROP 마이그레이션이 없어서 죽은 테이블만 stage/prod에 남아 있었다. 옛 평문 토큰이 방치돼 있던 셈이라 drop table if exists로 같이 지웠다. if exists를 쓴 이유는 이 테이블이 환경마다 있을 수도 없을 수도 있어서인데, 이것도 두 경우 다 마이그레이션이 실패하지 않는지 테스트로 확인했다.

토큰 노출 문제를 고치면서 dbclient 접근 자체에 대한 가시성도 같이 손봤다. 지금까지는 등록된 팀원 전원이 같은 시스템 계정(dbclient)으로 forced command를 통해 접속하는 구조라, SQL 로그를 남겨도 "누가" 실행했는지 구분이 안 됐다. sshd의 ExposeAuthInfo yes를 켜면 세션 환경변수로 실제 인증에 쓰인 SSH 공개키의 fingerprint를 알 수 있다는 걸 확인하고, 그 fingerprint를 authorized_keys의 각 줄과 대조해서 줄 끝 주석(이름)으로 매핑하는 방식을 dbclient-sqlite-guard.sh에 추가했다. 이 값이 비어있는 경우(서버에 아직 ExposeAuthInfo가 반영 안 됐거나 세션 정보가 없는 경우)는 fail-open이 아니라 fail-visible로 처리했다. 걸러야 할 SQL은 그대로 걸러지고, actor만 "unknown"으로 남아 감사로그가 비어있다는 사실 자체가 드러나게 했다.

같은 방식을 ubuntu 계정에도 쓰고 싶었는데, 여기는 상황이 달랐다. ubuntu는 sudo가 있는 일반 셸을 가진 인프라 오너 계정이라 forced command로 강제할 수 없다. 래퍼 함수를 만들어도 바이너리를 직접 호출하거나 .bashrc를 고치면 우회된다. 그래서 이건 처음부터 기술적 강제가 아니라 "평소엔 이 경로로 쓰고 그 기록이 남는다"는 관례 + 가시성 도구로 설계 방향을 다르게 잡았다. script 명령으로 세션 전체를 typescript 파일에 남기는 ubuntu-sqlite-audit.sh를 별도로 추가하고, 스크립트 주석에 auditd 파일감시라는 더 강한 대안도 같이 적어뒀다. auditd는 커널 syscall을 잡아서 우회가 훨씬 어렵지만 대신 "누가 언제 파일을 건드렸다"만 남고 SQL 문 텍스트 자체는 못 담는다는 한계가 있어서, 이 스크립트와는 대체 관계가 아니라 보완 관계라고 정리했다.

같은 PR에 로그인 실패 허용 횟수를 5회에서 10회로 완화하는 것도 넣었고, 커밋을 하나 더 쌓아서 멤버 전화번호 정규화도 처리했다. 관리자가 멤버를 한 명씩 등록할 때 전화번호에 하이픈을 넣는 경우가 있었는데(stage 실측 42명 중 일부가 하이픈 포함), phone 컬럼 값과 그 값으로 만드는 초기 비밀번호 해시가 사람마다 다른 형식으로 들어가고 있었다. MemberService.buildMember()에서 전화번호를 받는 즉시 하이픈을 제거하고, 그 정규화된 값을 phone 저장과 초기 비밀번호 해시 양쪽에 동일하게 쓰도록 고쳤다. resetPasswordByAdmin()도 저장된 phone을 그대로 재사용하는 구조라 별도 수정 없이 자동으로 일관성이 맞춰졌다. prod는 실측해보니 전원 하이픈 없는 일괄 등록 데이터라 기존 데이터를 손댈 필요는 없었고, stage 기존 데이터는 이번 범위 밖으로 남겨뒀다.

PR 설명에는 이번 범위 밖으로 남긴 것도 명시해뒀다. members.phone은 멤버 초기 비밀번호의 원문이기도 한데, 전화번호가 연락처로도 쓰이는 데이터라 그냥 해시로 바꿀 수가 없다. 이건 PM 결정이 필요한 사안이라 별도 이슈로 남겼다.