간편 로그인 보안 강화 사례

간편 로그인 보안 강화 사례

-전화번호 뒤 4자리가 비밀번호가 될 때-

1. 배경: 링크 로그인이라는 편의와 그 이면

제가 참여한 프로젝트는 대형 종합병원의 환자용 전자문진 시스템입니다. 환자는 진료 전에 병원이 발송한 문진(설문)을 모바일에서 작성하고, 의료진은 그 결과를 진료에 활용합니다. 시스템은 Keycloak 기반 인증, Java/Spring 마이크로서비스 백엔드, React 프론트엔드로 구성되어 있으며, 저는 운영 중이던 이 시스템에 결함 보수와 운영 지원 역할로 투입되었습니다. 이 글은 그 보수 업무 중에 만난 문제에서 출발한 이야기입니다.

이 시스템의 핵심 진입 경로는 "링크 로그인"입니다. 병원이 알림톡으로 발송한 문진 링크를 열고 본인 전화번호 뒤 4자리만 입력하면, 회원가입이나 비밀번호 없이 바로 문진을 작성할 수 있습니다.

image1.png

그림 1. 링크 로그인 진입 흐름

발단은 고객사의 보안 점검이었습니다. "링크 로그인으로 들어온 사용자가 정식 회원이라면 회원 화면 전체에 접근할 수 있는 것 아니냐"는 지적이 접수되었고, 재현해 보니 사실이었습니다. 링크 수신자가 이미 ID/PW로 가입한 정식 회원이면 Keycloak은 그 회원 계정으로 토큰을 발급하는데, 화면과 API가 로그인 방식을 전혀 구분하지 않아, 전화번호 뒤 4자리만 입력한 세션이 마이페이지·알림·전체 문진 이력까지 접근할 수 있었습니다. 정식 회원에게는 사실상 뒤 4자리가 비밀번호가 되는 구조로, 문자를 볼 수 있는 제3자가 경우의 수 1만 개짜리 숫자만으로 타인의 의료 관련 정보에 접근할 수 있다는 뜻입니다. 고객사로부터 보완 요구가 공식 접수되었습니다.

2. 요건 정의: "링크로 들어온 사용자는 게스트와 동일하게"

가장 단순한 해결책은 정식 회원의 링크 로그인을 막는 것이지만, 이는 "링크만 누르면 문진을 작성할 수 있다"는 서비스의 핵심 편의성을 무너뜨립니다. 고객사와의 협의 끝에 방향은 차단이 아닌 격하로 정리되었습니다. "링크 로그인 세션은 계정이 정식 회원이더라도 비회원(게스트)과 완전히 동일하게 취급한다"는 것입니다.

  • 링크 세션에는 이번에 발송된 문진의 작성 화면만 기본 제공합니다.

  • 과거 문진 이력은 [과거 내역 보기] 버튼에서 이름·생년월일 본인 확인을 통과한 경우에만 조회할 수 있습니다.

  • 마이페이지·알림 등 회원 전용 메뉴는 노출하지 않습니다. (키오스크 경로는 제외)

이렇게 요건이 정리되자 기술 과제는 하나로 좁혀졌습니다. 시스템이 "이 세션이 어떤 방식으로 로그인했는가"를 신뢰할 수 있게 아는 것입니다.

3. 기술 선택: 로그인 방식을 어디에 기록할 것인가

로그인 방식 정보를 어디에 두고 어떻게 전달할지 세 가지 방안을 검토했습니다.

  • ① 프론트엔드 상태(sessionStorage 등): 구현은 쉽지만 개발자 도구로 위변조할 수 있어 보안 판별 근거로는 신뢰할 수 없습니다.

  • ② 서버 세션: JWT 기반 무상태 구조와 맞지 않고, 여러 마이크로서비스가 각자 세션 저장소를 조회해야 하는 결합이 생깁니다.

  • ③ 토큰 claim: 인증 기관인 Keycloak이 서명한 토큰에 로그인 방식을 싣는 방법으로, 위변조가 불가능하고 프론트·백엔드 어디서나 추가 조회 없이 판별할 수 있습니다.

결론은 ③이었습니다. "누가 로그인했는가"뿐 아니라 "어떻게 로그인했는가"도 인증 기관이 보증할 정보라고 판단했고, 이번에는 화면 제한에만 쓰더라도 나중에 백엔드 API 차단으로 확장할 때 그대로 재사용할 수 있기 때문입니다.

3-1. Keycloak 구현: User Session Note와 mapper

이 시스템은 링크 로그인을 위해 이미 Keycloak SPI로 커스텀 Authenticator를 운영하고 있었습니다. 로그인 폼 파라미터에 따라 링크/키오스크/일반 ID/PW 로그인으로 분기하는 구조라, 링크 로그인 성공 시에만 User Session Note를 기록하는 코드를 추가했습니다.

// LoginAuthenticator.java
if (formData.containsKey(FIELD_PHONE_LAST_4)) {
    // [CASE 1] 링크(전화번호 뒤 4자리) 로그인
    user = handlePhoneLogin(context, formData);
    if (user != null) {
        // User Session Note mapper를 통해 토큰 claim으로 발급된다
        context.getAuthenticationSession()
            .setUserSessionNote("login_method", "link");
    }
} else {
    // 키오스크 / 일반 ID/PW 로그인 — note를 기록하지 않는다
    ...
}

User Session Note는 Keycloak 세션 내부에만 존재하는 값이므로, 토큰 claim으로 내보내려면 클라이언트 dedicated scope에 protocol mapper를 등록해야 합니다.

# Keycloak 관리 콘솔 — 클라이언트 dedicated scope > Add mapper (By configuration)
Mapper type          : User Session Note
User Session Note    : login_method
Token Claim Name     : login_method
Claim JSON Type      : String
Add to ID token      : On
Add to access token  : On

적용 후 두 로그인 방식의 토큰을 비교하면, 링크 로그인 토큰에만 claim이 존재하고 ID/PW 토큰에는 claim 자체가 없습니다.

// 링크 로그인 토큰 payload (일부)
{
  "preferred_username": "u-93f2...",
  "login_method": "link",   // <- mapper가 발급한 claim
  ...
}

// ID/PW 로그인 토큰 payload (일부) - login_method claim 자체가 없다
{
  "preferred_username": "u-93f2...",
  ...
}

이것은 의도된 fail-safe 설계입니다. 판별 로직은 "claim 값이 link일 때만 제한"하므로, 판별 코드에 결함이 있거나 토큰 파싱에 실패해도 결과는 항상 "제한하지 않음", 즉 정식 회원의 기존 동선이 깨지지 않는 방향으로 무너집니다.

4. 프론트엔드 적용: 판별 유틸 하나, 게이트 여섯 곳

프론트엔드에는 claim을 읽는 판별 함수를 공용 라이브러리에 하나만 두고, 화면 게이트들이 이를 참조하게 했습니다.

/** 링크 로그인 세션 여부 — ID/PW 토큰에는 claim 자체가 없다 */
export const isLinkLoginSession = (): boolean => {
  const token = getAccessToken();
  if (!token) return false;
  try {
    return JSON.parse(atob(token.split('.')[1]))?.login_method === 'link';
  } catch {
    return false;   // 파싱 실패 시에도 "제한하지 않음" (fail-safe)
  }
};

기존 화면들은 비회원 여부(isGuest)로 게스트 모드를 처리하고 있었으므로, 링크 세션을 더한 통합 개념 isRestricted를 권한 체크 훅에 도입하고 화면들은 이 값만 보도록 했습니다.

const isGuest = user?.isGuest ?? false;
const isLinkSession = isLinkLoginSession();
const isRestricted = isGuest || isLinkSession;  // 제한 세션

이 기준을 라우팅 레이아웃, 메뉴 훅, 첫 진입 페이지, 환자 대시보드, 공통 헤더, 문진 목록까지 여섯 곳의 게이트에 적용해 제한 세션에는 발송된 문진과 [과거 내역 보기]만 남겼습니다. 본인 확인을 통과한 뒤에는 홈·문진 탭·상세 나가기 어디서든 동일하게 과거 이력 페이지로 이동하도록 동선을 통일하고, 모바일 하단 내비게이션은 본인 확인 완료 시에만 노출했습니다.

5. API 레이어: 개방과 방어의 균형

화면을 게스트 수준으로 맞추자 반대 방향의 문제가 생겼습니다. 게스트 화면이 호출하는 API 세 곳(본인 확인 검증, 게스트 문진 목록, 게스트 이력 조회)이 ROLE_GUEST 전용으로 잠겨 있어, ROLE_USER 토큰을 가진 링크 세션 회원에게도 열어야 했습니다. 권한을 넓히는 변경이라 안전성을 별도로 검증했는데, 세 API 모두 조회 대상 환자를 클라이언트 입력이 아닌 토큰의 인증 주체에서 역산하고, Redis에 기록된 본인 확인 통과 플래그(TTL 30분)를 재검증한 뒤에야 데이터를 반환하도록 설계되어 있어 타인 데이터에 접근할 경로는 생기지 않았습니다.

반대로 "링크 세션이면 회원 전용 API를 백엔드에서 원천 차단"하는 작업은 이번 범위에서 제외하기로 결정했습니다. 기존에도 화면 수준 제한만 있던 영역이고 회원 전용 API들도 같은 자체 방어를 갖추고 있어 실질 위험이 낮았기 때문입니다. 다만 claim이 Access Token에 실려 있으므로, 필요해지면 백엔드 인가 로직이 claim을 읽어 차단하는 방식으로 확장할 기반은 마련되었습니다.

image2.png

그림 2. 전체 아키텍처 — 로그인 방식 claim의 전파와 판별 지점

6. 운영 배포에서 배운 것: 조용히 무력화되는 설정

가장 크게 배운 지점은 코드가 아니라 배포였습니다. Authenticator(SPI)는 코드라서 배포 파이프라인을 타지만, mapper는 Keycloak realm의 "설정"이라 관리 콘솔에서 환경별로 직접 등록해야 합니다. 문제는 이 설정이 누락되어도 아무 오류가 나지 않는다는 점입니다. claim이 발급되지 않을 뿐이고, 판별 함수는 fail-safe 설계에 따라 조용히 false를 반환하므로, 모든 것이 정상으로 보이는 상태에서 화면 제한만 사라집니다. 이런 "조용한 실패"는 사용자 신고로도 발견되지 않습니다.

그래서 배포 체크리스트에 다음을 명시했습니다.

  • 운영 반영 시 Keycloak 콘솔에 User Session Note mapper 수동 등록 (DEV/STG와 동일 설정)

  • 배포 직후 링크 로그인 토큰에 login_method claim이 존재하는지, ID/PW 토큰에는 없는지 교차 확인

  • 링크 세션에서 회원 전용 메뉴가 노출되지 않는지 화면 최종 확인

이후로는 코드 배포만으로 완성되지 않는 변경, 특히 실패가 조용한 설정일수록 배포 직후의 능동적 검증 절차까지 체크리스트로 관리하고 있습니다.

7. 마무리

결함 보수 지원으로 시작한 일이었지만, 결과적으로 인증 구조를 개선하는 작업이 되었습니다. 링크 로그인의 편의성은 유지하면서 전화번호 뒤 4자리만으로 개인정보 화면에 접근할 수 있던 취약점을 제거했고, 그 과정에서 얻은 관점은 다른 프로젝트에서도 유효하다고 생각합니다.

  • 인증 "방식"도 인증 기관이 보증할 정보입니다. 로그인 방식을 서명된 토큰 claim으로 싣는 패턴은 간편 인증·소셜 로그인 등 인증 경로가 여러 개인 시스템 어디에나 적용할 수 있습니다.

  • 실패의 방향을 먼저 설계해야 합니다. "claim이 있을 때만 제한"하는 구조 덕분에 어떤 결함도 기존 사용자를 깨뜨리는 방향으로 번지지 않았습니다.

  • 코드 밖의 설정은 체크리스트로 관리해야 합니다. 누락 시 오류 없이 조용히 무력화되는 설정은 반드시 배포 직후 검증 절차와 함께 문서화해야 합니다.

eunice

Site footer