| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 프론트엔드
- 아키텍처
- Vite
- React
- REST API
- 도메인설계
- 마이그레이션
- 상태관리
- sqlite
- javascript
- 온디바이스AI
- 백엔드
- python
- php
- 크롤링
- JWT
- 메일자동화
- electron
- Android
- 배포
- next.js
- 인증
- playwright
- 보안
- github actions
- FastAPI
- typescript
- 소셜로그인
- Kotlin
- contextBridge
- Today
- Total
HM소프트
온프레미스 음성 AI 상담 시스템 개발기 (3) — 관리자 콘솔과 보안 다지기 본문

관리자 콘솔을 받치는 인증·역할·보안을 다진다. 신뢰 경계를 사람 세션 쿠키와 머신 API 키로 가르고, owner/reception 역할과 테넌트 격리를 의존성 계층으로 쌓았다. 1회용 부트스트랩 토큰, 보안 리뷰에서 깨진 기본 시크릿·서버측 세션 폐기, scrypt·HMAC 표준 라이브러리 해시까지 기록한다.
AI보다 콘솔이 먼저 팔린다
1편은 음성 런타임, 2편은 AI 두뇌(예약 tool-call·RAG·가드레일)였다. 마지막 3편은 그걸 운영 가능하게 만드는 일이다. 사실 이 단계가 체감상 가장 "제품"에 가까웠다. 한 의료기관 입장에서 매일 들여다보는 화면은 AI 내부가 아니라 관리자 콘솔이기 때문이다. 승인 대기 예약을 처리하고, 통화 로그를 확인하고, 시술과 가드레일을 설정하는 그 화면이 곧 제품의 얼굴이다.
이번 편의 무게중심은 두 가지다. ① 콘솔을 받치는 인증·역할·테넌트 격리, ② 보안 리뷰에서 드러난 구멍들을 메운 과정이다. 온프레미스라는 제약이 보안 설계를 어떻게 끌고 갔는지가 이 편의 진짜 주제다. 설치본이 고객 서버에 통째로 남는다는 사실이 모든 결정을 보수적으로 만들었다. 코드로는 routers/auth.py, deps.py, routers/admin_users.py, util.py, 그리고 설정인 config.py다.
신뢰 경계를 둘로 가른다 — 사람 세션 vs 머신 키
가장 먼저 결정한 건 "누가 이 API를 호출하는가"에 따라 보호 방식을 둘로 나누는 것이었다. 관리자 콘솔은 사람이 브라우저로 쓰고, AI 런타임(통화 세션·턴·예약 tool-call·슬롯 조회)은 전화 게이트웨이나 런타임 프로세스 같은 머신이 호출한다. 이 둘은 인증 모델이 근본적으로 다르다. 사람은 로그인·로그아웃·역할이 있고, 머신은 발급된 키로 식별된다.

이 분리는 deps.py의 의존성으로 강제된다. 관리자 엔드포인트는 get_current_admin(세션 쿠키)으로, 런타임 엔드포인트는 require_machine(머신 키)으로 보호한다.

키 비교에 ==가 아니라 hmac.compare_digest를 쓴 게 포인트다. 일반 문자열 비교는 첫 불일치 글자에서 멈춰, 비교에 걸린 시간으로 키를 한 글자씩 추측하는 타이밍 공격에 노출된다. compare_digest는 길이가 같으면 항상 같은 시간이 걸리도록 비교한다. 비밀값 비교에는 무조건 이걸 쓴다.
역할은 owner와 reception 둘로
콘솔 인증에 역할을 두 개로 갈랐다. owner(기관 운영자)와 reception(데스크 담당자). 데스크 담당자는 통화·예약을 처리하지만, 계정 관리나 가드레일 정책 같은 민감한 설정은 owner만 만질 수 있다. 이걸 의존성 계층으로 표현했다. get_current_admin 위에 hospital_admin(테넌트 격리)을 얹고, 그 위에 hospital_owner(역할 가드)를 얹는 식이다.

hospital_admin의 admin.hospital_id != hospital_id 한 줄이 테넌트 격리의 핵심이다. 로그인한 관리자가 자기 기관이 아닌 다른 기관의 데이터에 접근하려 하면 403으로 막는다. 멀티테넌트에서 이게 빠지면 A 기관 관리자가 URL의 기관 id만 바꿔 B 기관 통화 로그를 보는 사고가 난다. 그리고 의존성을 계단처럼 쌓았기 때문에, 엔드포인트는 필요한 보호 수준의 의존성 하나만 선언하면 그 아래 단계(로그인 → 소속 일치 → 역할)가 전부 자동으로 검증된다.
인증 방식은 세션 쿠키로 갔다. 온프레미스 내부망 콘솔에 무거운 토큰 인프라를 끌어들일 이유가 없었다. 로그인하면 HMAC으로 서명한 httpOnly 쿠키를 발급하고, 운영(HTTPS)에서는 Secure 플래그를 켠다.
닭이 먼저냐 — 부트스트랩 토큰
콘솔에 인증을 붙이자마자 고전적인 닭-달걀 문제가 나왔다. "첫 관리자 계정은 누가 만드는가?" 계정을 만들려면 로그인이 필요한데, 로그인하려면 계정이 있어야 한다. 온프레미스에서는 더 까다롭다 — 개발사가 원격으로 계정을 박아 넣을 수 없는 폐쇄망일 수 있기 때문이다. "계정 발급 때문에 개발사에 연락해야 하는 제품"은 온프레미스에서 운영이 안 된다.
그래서 1회용 부트스트랩 토큰으로 풀었다. 기관을 생성할 때 고엔트로피 토큰을 발급해 그 해시를 기관 행에 저장해 두고, 첫 owner 계정은 이 토큰을 가진 호출자만 무인증으로 만들 수 있게 했다. 토큰은 한 번 쓰면 즉시 소거된다.

"관리자가 0명일 때만, 토큰을 든 사람이, owner 강제로" 첫 계정을 만든다. 토큰을 저장할 때 평문이 아니라 해시로 저장하고 비교도 compare_digest로 한다. 이렇게 하면 DB가 유출돼도 토큰 원문은 드러나지 않고, 무인증 엔드포인트지만 토큰이 "기관 생성자"에게 귀속되므로 관리자 0명인 기관을 아무나 탈취하는 일도 막힌다. 이후 계정 추가는 owner 로그인이 필요하고, 역할도 화이트리스트(_VALID_ROLES)로 제한해 임의 역할 주입을 막았다.
보안 리뷰에서 깨진 것들
기능이 어느 정도 모이고 보안 리뷰를 돌렸다. 예상대로 깨졌다. 세 가지가 핵심이었다.

① 기본 시크릿 제거. 세션 쿠키 서명에 쓰는 비밀 키에 개발 편의로 디폴트 값을 둘 뻔했다. 온프레미스는 설치본이 고객 서버에 통째로 남는다 — "나중에 바꾸겠지"가 통하지 않는다. 디폴트 시크릿이 박힌 채로 출고되면 모든 설치본이 같은 키로 토큰을 서명하게 되고, 한 곳의 키만 알면 전부의 세션을 위조할 수 있다. 그래서 하드코딩 기본값을 아예 없애고, 환경변수가 없으면 부팅마다 강한 랜덤을 새로 생성하게 했다.

default_factory로 매번 랜덤을 만들면 키를 깜빡 주입하지 않아도 "예측 가능한 약한 키"로 뜨는 일은 없다. 다만 재시작하면 키가 바뀌어 세션이 풀리므로, 운영에서는 SECRET_KEY를 고정하라고 안내한다. "안전하지 않은 채로 도느니, 불편하더라도 안전한 쪽이 기본"이라는 원칙이다.
② 서버측 세션 폐기. 가장 교과서적인 지적이었다. 초기 로그아웃은 쿠키 삭제뿐이었다. 그런데 토큰 자체는 만료 시각까지 유효하게 서명돼 있어서, 탈취당한 쿠키는 로그아웃해도 만료까지 계속 살아 있었다. stateless 서명 토큰의 고질적 약점이다. 이걸 서버측 세션 allowlist로 보완했다. 로그인 시 세션 행(sid)을 만들고 토큰에 sid를 담아, 매 요청마다 그 sid가 폐기·만료되지 않았는지 서버에서 확인한다.

토큰의 서명·만료(stateless)와 서버측 세션 상태(stateful)를 둘 다 통과해야 유효하다. 이러면 로그아웃은 그 sid에 revoked_at을 찍는 것만으로 탈취 토큰까지 즉시 무효화한다. "모든 기기에서 로그아웃"도 해당 관리자의 모든 세션 행을 한 번에 폐기하면 끝난다.

allowlist에는 자연스러운 후속 문제가 따라온다 — 세션 행이 계속 쌓인다는 것. 2편에서 만든 유지보수 스윕이 여기서 다시 일한다. 만료된 세션 행을 주기적으로 정리하되, 폐기됐지만 아직 만료 전인 행은 토큰 만료까지 남겨 둔다. 그래야 탈취 토큰의 재사용이 만료 시점까지 확실히 차단된다.
③ 머신 API 키 + 부트스트랩 토큰. 앞에서 본 사람/머신 경계 분리와 부트스트랩이 사실 이 리뷰의 산물이다. 런타임 프로세스가 백엔드를 호출할 때 쓸 머신 키 체계를 세우고, 사람 세션과 머신 키를 분리해 권한과 폐기를 따로 다룰 수 있게 했다. 사람 계정이 털리는 것과 머신 키가 털리는 것은 영향 범위와 대응이 다르므로, 처음부터 갈라 두는 게 옳다.
비밀번호와 토큰 — 표준 라이브러리만으로
해시 유틸도 짚어 둘 만하다. 외부 의존성을 늘리지 않으려고 표준 라이브러리만으로 비밀번호는 scrypt(사용자별 salt), 세션 토큰은 HMAC-SHA256 서명으로 처리했다. 온프레미스는 의존성 하나가 곧 공급망 위험이라 가볍게 가는 게 유리하다.
_SCRYPT = {"n": 2**14, "r": 8, "p": 1, "dklen": 32}
def hash_password(password: str, *, salt: bytes | None = None) -> str:
"""scrypt 해시 → 'scrypt$<salt_hex>$<hash_hex>' 형식."""
salt = salt or os.urandom(16)
dk = hashlib.scrypt(password.encode(), salt=salt, **_SCRYPT)
return f"scrypt${salt.hex()}${dk.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
algo, salt_hex, hash_hex = stored.split("$")
if algo != "scrypt":
return False
dk = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex), **_SCRYPT)
return hmac.compare_digest(dk.hex(), hash_hex) # 타이밍 안전
except (ValueError, AttributeError):
return False
scrypt는 메모리·연산을 의도적으로 많이 쓰는 KDF라 무차별 대입에 강하고, salt를 사용자마다 따로 둬서 레인보우 테이블을 무력화한다. 검증도 compare_digest로 타이밍을 일정하게 유지한다. 알고리즘 식별자(scrypt$...)를 해시 문자열 앞에 붙여 둔 건, 나중에 파라미터를 올리거나 알고리즘을 바꿀 때 마이그레이션 여지를 남기기 위해서다.
정리하며
- 신뢰 경계를 사람 세션 vs 머신 키로 갈랐다. 콘솔은 세션 쿠키(
get_current_admin), 런타임은 머신 키(require_machine)로 보호하고, 키 비교는 전부 타이밍 안전(compare_digest). - 역할은 owner/reception 둘로, 의존성을 로그인 → 테넌트 격리 → 역할 가드로 계단처럼 쌓아 엔드포인트가 한 줄로 적정 보호를 선언하게 했다.
- 첫 계정 닭-달걀 문제는 1회용 부트스트랩 토큰(해시 저장·1회 소거)으로 풀어, 폐쇄망에서도 개발사 개입 없이 운영을 시작할 수 있게 했다.
- 보안 리뷰 수정 3종: 하드코딩 기본 시크릿 제거(미주입 시 랜덤·운영은 고정), 서버측 세션 폐기(서명+allowlist 이중 검증으로 탈취 토큰 즉시 무효화), 머신 키·부트스트랩 분리.
- 비밀번호는 scrypt, 토큰은 HMAC-SHA256으로 표준 라이브러리만 써서 온프레미스 공급망 위험을 줄였다.
세 편으로 시리즈를 마친다. 온프레미스라는 제약 하나가 설계 전반 — 계약 우선, mock 기본, seam, 인프로세스 스윕, 시크릿·세션 정책 — 을 어떻게 일관되게 끌고 갔는지가 이 프로젝트의 가장 큰 배움이었다. 다음 마일스톤은 실제 GPU 환경에서의 정확도·지연 실측과 파일럿 범위 결정이다.
'외주 개발일지' 카테고리의 다른 글
| 커피머신 키오스크 UI 개발기 (3) — GitHub Pages 배포와 CDN 장애 우회 (0) | 2026.06.21 |
|---|---|
| 예술품 경매 데이터 수집 플랫폼 개발기 (1) — 분산 크롤러 인프라 설계 (1) | 2026.06.18 |
| 온프레미스 음성 AI 상담 시스템 개발기 (2) — 예약 tool-call과 DB 기반 RAG (0) | 2026.06.18 |
| 온프레미스 음성 AI 상담 시스템 개발기 (1) — 상담 런타임과 오디오 파이프라인 (0) | 2026.06.18 |
| 팬 응원 플랫폼 개발기 (1) — Firebase 없이도 도는 데모 모드와 정적 배포 (0) | 2026.06.15 |