제 목 :
우리집 비서 만들기 프로젝트 #3-1 - 서버편 첫번째 이야기

지난 2편에서는 집 서버를 밖으로 열기 전에 문부터 잠갔다. NAS 포트는 하나도 열지 않고, Cloudflare Tunnel로 안에서 밖으로 터널을 뚫고, 그 앞을 Cloudflare Access가 지키게 했다. 그리고 그 터널 끝에는 받은 요청을 그대로 보여 주는 whoami라는 허수아비가 앉아 있었다.

이번 편에서는 드디어 그 허수아비를 치우고, 그 자리에 진짜 비서 Secretary를 앉힌다. 그런데 써 놓고 보니 한 편으로는 너무 길어서 두 편으로 나눴다. 이번 첫 번째 이야기에서는 2편에서 설계만 해 두었던 HMAC 서명 인증, 그러니까 Secretary의 문지기를 실제 코드로 옮긴다. 메일 발송과 감사 로그, 배포에 필요한 파일들은 두 번째 이야기에서 이어진다.
그리고 만들다 보니 질문이 하나 생겼다. “이거 나 혼자 쓰는 거야? 아내는?” 그래서 중간에 설계를 한 번 고쳐서, 가족 최대 4명이 함께 쓰는 서비스로 만들었다. 나중에 이 소스를 다른 사람과 나눌 수 있게 집마다 다른 값은 전부 밖으로 빼 두었다.
코드가 꽤 많다. 그래도 하나씩 따라가 보자.

이번에 만드는 것
이번 첫 번째 이야기에서 만드는 것:
- HMAC 요청 서명 인증: 기기마다 키를 따로 주고, 요청마다 서명을 붙인다. 타임스탬프 ±60초, Nonce 재사용 차단, 실패가 몰리면 키 잠금
- 가족 단위 사용자: 관리자, 어른 가족, 아이 세 가지 역할. 기기는 사람에게 속하고, 최대 4명
- scope 권한: 기기마다, 그리고 역할마다 할 수 있는 일을 제한한다. 안경은 요청만, 워치는 승인만, 아이는 승인 불가
- 사용자·기기 관리 CLI: 가족 등록과 키 발급은 NAS 안에서만
완성된 폴더 구조는 이렇다.
09.Secretary/├── app/│ ├── main.py # FastAPI 앱, 오류 응답 통일│ ├── config.py # 설정 (환경 변수)│ ├── db.py # SQLite: Nonce, 인증 실패, 승인 대기열│ ├── audit.py # 감사 로그 (JSONL)│ ├── cli.py # 사용자 등록, 기기 키 발급/폐기 CLI│ ├── auth/│ │ ├── scopes.py # 권한 범위, 역할, 기기 프리셋│ │ ├── registry.py # 사용자와 기기 저장소│ │ ├── signing.py # HMAC 서명 만들기│ │ └── guard.py # 문지기: 모든 요청 검증│ ├── modules/│ │ ├── mail.py # AWS SES 메일 발송│ │ └── approvals.py # 승인 대기열│ └── routers/│ ├── system.py # GET /v1/me│ ├── mail.py # POST /v1/mail│ └── approvals.py # 승인 목록, 승인, 거절├── deploy/deploy.sh # NAS에서 도는 빌드·배포 스크립트├── scripts/secretary_client.py # 예제 클라이언트├── tests/ # pytest (28개)├── Dockerfile├── docker-compose.yml├── Jenkinsfile├── requirements.txt└── .env.example(AI에게 부탁해 만든 폴더 구조다. 나한테는 이런걸 작성할 재주는 없다.)
Step 0. 왜 Python, 그리고 FastAPI인가
평소에 나는 Spring Boot나 요즘은 Next.js 또는 Nest.js를 주로 쓴다. 그런데 이번에는 FastAPI를 골랐다. 이유는 단순하다.
- 연동할 라이브러리가 파이썬 쪽에 다 있다. 귀국하면 붙일 Tapo 전구는 비공식
tapo라이브러리가 L535를 지원하고, AWS는boto3가 있다. Gitea, 카카오도 파이썬으로 다루기 편하다. - 가볍다. NAS에서 24시간 도는 개인 서버에 JVM을 띄우고 싶지는 않았다. 자원을 너무 많이 쓴다.
- 요청 검증이 공짜다. Pydantic 모델만 선언하면 입력값 형식, 길이 검증을 알아서 해 준다.
Step 1. 프로젝트 만들기
먼저 의존성이다. requirements.txt 파일을 아래와 같이 만들었다.
fastapi==0.162.0uvicorn[standard]==0.54.0pydantic-settings==2.16.2email-validator==2.3.0boto3==1.43.123테스트용 의존성은 따로 뺐다. 실행 이미지에는 테스트 도구가 들어가지 않게 하려고 분리해뒀다.
-r requirements.txtpytest==9.1.1httpx==0.28.1혹시나 requirements.txt 의존성을 설치하는 방법을 모르는 분들을 위해서 아래의 명령어를 소개한다.
(venv) python install -r requirements.txt # 프로그램 의존성 설치(venv) python install -r requirements-dev.txt # 테스트 의존성 설치그리고 가장 중요한 파일, .gitignore. 시큐어 코딩 글에서 “Git은 잊지 않는다”고 했다. .env와 data/ 폴더는 처음부터 저장소에 못 들어오게 막는다. data/ 폴더는 프로그램에서 만드는 계정정보, 로그, DB정보 등등이 들어간다. 노출되면 안되니 Git에 올리면 안된다.
__pycache__/*.pyc.pytest_cache/.venv/.envdata/설정값은 .env로 받는다. 저장소에는 빈 껍데기인 .env.example만 올린다.
# Cloudflare Tunnel 토큰 (터널 만들 때 받은 eyJ... 값)TUNNEL_TOKEN=
# Cloudflare Access를 거친 요청만 받기 (cloudflared가 붙이는 JWT 헤더 확인)SECRETARY_REQUIRE_CF_ACCESS=true
# HMAC 타임스탬프 허용 오차(초)SECRETARY_CLOCK_SKEW_SECONDS=60
# 승인 대기 유효 시간(초). 지나면 자동 만료SECRETARY_APPROVAL_TTL_SECONDS=300
# AWS SES (IAM 키는 ses:SendEmail 하나만 허용된 전용 키로)SECRETARY_SES_REGION=ap-northeast-2SECRETARY_SES_FROM=Secretary <secretary@example.com># 보낼 수 있는 수신자. 쉼표로 구분, "@도메인"은 도메인 전체 허용# (사용자 등록 때 넣은 본인 이메일로는 이 목록과 상관없이 항상 보낼 수 있다)SECRETARY_MAIL_ALLOWED_RECIPIENTS=me@example.com,@example.comAWS_ACCESS_KEY_ID=AWS_SECRET_ACCESS_KEY=
# 활성 사용자 최대 인원 (한 집 기준)SECRETARY_MAX_USERS=4Step 2. 설정 (config.py)
pydantic-settings를 쓰면 환경 변수를 타입이 있는 설정 객체로 바로 읽을 수 있다. 접두어를 SECRETARY_로 정해서, 다른 환경 변수와 섞이지 않게 했다.
from functools import lru_cachefrom pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): """서버 설정. SECRETARY_ 로 시작하는 환경 변수(또는 .env)에서 값을 읽는다.
예: SECRETARY_MAX_USERS=3 → max_users = 3. 환경 변수가 없으면 아래 기본값을 쓴다. """
model_config = SettingsConfigDict(env_prefix="SECRETARY_", env_file=".env", extra="ignore")
data_dir: Path = Path("/data")
# 한 집에서 쓰는 서비스라 활성 사용자 수에 상한을 둔다 max_users: int = 4
# 인증 clock_skew_seconds: int = 60 max_body_bytes: int = 64 * 1024 require_cf_access: bool = True lockout_threshold: int = 10 # 이 횟수만큼 실패하면 lockout_window_seconds: int = 300 # 이 시간 동안 해당 키를 잠근다
# 승인 approval_ttl_seconds: int = 300
# 메일 (AWS SES) ses_region: str = "ap-northeast-2" ses_from: str = "" mail_allowed_recipients: str = ""
@property def db_path(self) -> Path: """SQLite 파일 위치 (Nonce, 인증 실패, 승인 대기열).""" return self.data_dir / "secretary.db"
@property def registry_path(self) -> Path: """사용자·기기 저장 파일 위치. 기기 Secret이 들어 있어 권한 600으로 만든다.""" return self.data_dir / "registry.json"
@property def audit_path(self) -> Path: """감사 로그 파일 위치.""" return self.data_dir / "audit.log"
@property def allowed_recipients(self) -> list[str]: """쉼표로 이은 수신자 허용 목록을 리스트로 바꾼다.
"Dad@Example.com, @family.com" → ["dad@example.com", "@family.com"] (공백 제거, 소문자, 빈 칸 무시) """ return [r.strip().lower() for r in self.mail_allowed_recipients.split(",") if r.strip()]
@lru_cachedef get_settings() -> Settings: """설정 객체를 돌려준다. @lru_cache 덕분에 처음 한 번만 만들고 그 뒤로는 같은 객체를 재사용한다.""" return Settings()max_users는 한 집에서 활성 사용자를 몇 명까지 둘지 정한다. 기본 4명이다. lockout_threshold와 lockout_window_seconds는 뒤에서 나올 “키 잠금”에 쓴다. 5분 안에 10번 실패하면 그 키를 잠시 막는다.
@lru_cache가 뭐지?코드 맨 아래에 get_settings() 함수 위에 붙은 @lru_cache. 솔직히 나도 이번에 처음 봤다. 그래서 정리해 둔다.
@lru_cache는 파이썬 표준 라이브러리 functools에 들어 있는 캐시 데코레이터다. 함수 위에 붙이면, 그 함수의 결과를 기억해 뒀다가 다음에 부를 때는 함수를 다시 실행하지 않고 기억해 둔 값을 돌려준다.
여기서 잠깐 @lru_cache에 대해서 알아보는 시간
말로만 하면 감이 안 오니 직접 돌려 보자.
from functools import lru_cache
@lru_cachedef get_settings(): print("설정 파일 읽는 중...") return {"max_users": 4}
a = get_settings()b = get_settings()c = get_settings()print(a is b is c)$ python3 lru_demo.py설정 파일 읽는 중...True세 번 불렀는데 “설정 파일 읽는 중…“은 딱 한 번만 찍혔다. 두 번째부터는 함수 안으로 들어가지도 않고 처음 만든 결과를 그대로 돌려줬다는 뜻이다. 마지막 줄의 True는 a, b, c가 완전히 같은 객체라는 뜻이다.
@lru_cache 한 줄을 지우고 다시 돌리면 이렇게 된다.
$ python3 lru_demo.py설정 파일 읽는 중...설정 파일 읽는 중...설정 파일 읽는 중...False부를 때마다 매번 새로 읽고, 매번 다른 객체가 만들어진다.
왜 설정에 이걸 붙였나
Settings()를 만들 때마다 환경 변수와 .env 파일을 읽고, 타입이 맞는지 검증까지 한다. 그런데 설정은 앱이 돌아가는 동안 바뀔 일이 없다. 그러니 한 번만 만들어서 다 같이 쓰면 된다.
- 매번 다시 읽지 않는다. 파일을 읽고 검증하는 일을 처음 한 번만 한다.
- 설정이 하나로 통일된다. 앱 어디서
get_settings()를 부르든 같은 객체를 받는다. 실행 중에 누가 환경 변수를 바꿔도 앱 안의 설정이 중간에 달라지지 않는다.
Spring을 써 본 사람이라면 @Configuration으로 만든 싱글톤 빈을 떠올리면 비슷하다. FastAPI 공식 문서에서도 설정 객체를 이렇게 만드는 방법을 소개한다.
이름이 왜 LRU?
LRU는 Least Recently Used의 줄임말이다. 함수에 인자가 있으면 인자별로 결과를 따로 기억하는데, 기억할 칸(기본 128개)이 꽉 차면 가장 오랫동안 안 쓴 것부터 버린다는 뜻이다. get_settings()는 인자가 없어서 기억할 값이 하나뿐이니, 여기서는 사실상 “딱 한 번만 만들기” 용도로 쓴 셈이다.
조심할 점
한 번 만들어지면 다시 읽지 않으니, .env를 고쳐도 앱을 재시작하기 전까지는 반영되지 않는다. 우리 구조에서는 .env가 바뀌면 배포할 때 컨테이너를 새로 띄우니까 문제없다. 혹시 기억해 둔 값을 비워야 할 때는 get_settings.cache_clear()를 부르면 된다. 테스트에서 설정을 바꿔 가며 돌릴 때 이런 게 필요한데, Secretary는 아예 create_app(settings)처럼 설정을 직접 넘길 수 있게 만들어서 캐시를 건드리지 않고 테스트한다.
Step 3. 상태 저장소 (db.py)
서버가 기억해야 할 게 세 가지 있다.
- 이미 쓴 Nonce: 같은 요청을 다시 보내는 재전송 공격을 막으려면, 한 번 쓴 Nonce를 기억해야 한다.
- 인증 실패 기록: 실패가 몰리는 키를 잠그려면 실패 횟수를 세야 한다.
- 승인 대기열: 메일 발송 요청이 승인을 기다리는 동안 어딘가 있어야 한다.
첫 번째 Nonce가 왜 필요한지는 그림으로 보는 게 빠르다.

공격자가 내 요청을 중간에서 통째로 복사해서 다시 보내면, 타임스탬프도 서명도 원본 그대로라 둘 다 통과해 버린다. 이걸 막는 게 Nonce다. 한 번 쓴 Nonce를 기억해 두면, 같은 Nonce로 온 두 번째 요청은 막힌다.
그럼 공격자가 Nonce만 새 값으로 바꿔서 보내면? Nonce는 서명할 문자열에 들어 있어서 서명이 틀려진다. 기록이 지워질 때까지 기다렸다가 보내면? 이번에는 타임스탬프가 ±60초를 벗어난다. 그래서 Nonce는 영원히 기억할 필요 없이 120초만 기억하면 된다. 말로 설명을 써놨지만 복잡하고 어렵다. 그래서 이 검사 순서는 Step 7 문지기에서 코드로 다시 보겠다.(아이고 머리야)

처음에는 Nonce를 메모리에만 들고 있으려고 했다. 그런데 그러면 서버가 재시작되는 순간 기억이 날아가고, 재시작 직후 60초 동안은 같은 요청을 다시 보내도 막을 방법이 없다. 그래서 전부 SQLite 파일 하나에 넣었다. 개인 서버라 DB 서버를 따로 띄울 이유도 없다.
"""SQLite 하나에 Nonce 기록, 인증 실패 기록, 승인 대기열을 둔다.
uvicorn 워커는 1개로 운영한다. 개인 서버라 충분하고, 상태를 한 파일에 모을 수 있다."""import sqlite3import threadingfrom contextlib import contextmanagerfrom pathlib import Path
_lock = threading.Lock()
SCHEMA = """CREATE TABLE IF NOT EXISTS nonces ( access_key TEXT NOT NULL, nonce TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (access_key, nonce));CREATE TABLE IF NOT EXISTS auth_failures ( access_key TEXT NOT NULL, at INTEGER NOT NULL);CREATE INDEX IF NOT EXISTS idx_auth_failures ON auth_failures (access_key, at);CREATE TABLE IF NOT EXISTS approvals ( id TEXT PRIMARY KEY, action TEXT NOT NULL, payload TEXT NOT NULL, summary TEXT NOT NULL, requested_user TEXT NOT NULL, requested_by TEXT NOT NULL, rule TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, decided_by TEXT, decided_at INTEGER, result TEXT);CREATE INDEX IF NOT EXISTS idx_approvals_user ON approvals (requested_user, status);"""
class Database: """SQLite 연결 하나를 감싼 클래스. 모든 읽기/쓰기는 tx()를 거친다."""
def __init__(self, path: Path): """DB 파일을 열고(없으면 만들고) 테이블을 준비한다.
check_same_thread=False: 여러 스레드가 이 연결 하나를 같이 쓴다(동시 접근은 _lock으로 막는다). isolation_level=None: 트랜잭션 시작/끝을 tx()에서 직접 정한다. WAL: 쓰는 동안에도 읽기가 막히지 않는 저널 방식. """ path.parent.mkdir(parents=True, exist_ok=True) self._conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._conn.executescript(SCHEMA)
@contextmanager def tx(self): """트랜잭션 하나를 연다. with 블록이 무사히 끝나면 COMMIT, 예외가 나면 ROLLBACK.
with db.tx() as c: c.execute("INSERT ...")
BEGIN IMMEDIATE는 시작하자마자 쓰기 잠금을 잡아서, "읽고 → 확인하고 → 쓰기" 사이에 다른 요청이 끼어들지 못하게 한다(Nonce 중복 확인, 승인 한 번만 처리에 필요). """ with _lock: self._conn.execute("BEGIN IMMEDIATE") try: yield self._conn self._conn.execute("COMMIT") except Exception: self._conn.execute("ROLLBACK") raise
def close(self) -> None: """DB 연결을 닫는다. 서버 종료 때, CLI가 잠깐 열었다 닫을 때 쓴다.""" self._conn.close()BEGIN IMMEDIATE로 트랜잭션을 열고 락까지 걸어 둔 이유는, 같은 승인 요청에 두 기기가 동시에 “승인”을 눌러도 메일이 한 번만 나가게 하기 위해서다. 그리고 uvicorn 워커는 1개로 운영한다. 워커가 여러 개면 프로세스마다 락이 따로 놀기 때문이다. 혼자 쓰는 집 서버에 워커 1개면 차고 넘친다.
with와 @contextmanager가 뭐지?위 코드의 tx()는 with self.db.tx() as c:처럼 쓴다. 이 with가 처음 보면 낯설다.
with는 “시작할 때 할 일”과 “끝날 때 할 일”을 짝지어 주는 문법이다. 파일을 열면 꼭 닫아야 하고, 트랜잭션을 시작하면 꼭 COMMIT이나 ROLLBACK을 해야 한다. 이런 짝을 깜빡하지 않게, with 블록이 끝나면 정리 작업이 자동으로 돈다. 중간에 에러가 나도 마찬가지다.
@contextmanager는 이런 with용 도구를 함수 하나로 쉽게 만들게 해 준다. yield 앞이 “시작할 때”, yield 뒤가 “끝날 때”다.
from contextlib import contextmanager
@contextmanagerdef tx(): print("BEGIN") try: yield "conn" print("COMMIT") except Exception: print("ROLLBACK") raise
with tx() as c: print("작업:", c)
try: with tx() as c: raise ValueError("중간에 실패")except ValueError: print("에러는 밖으로 전달됨")$ python3 with_demo.pyBEGIN작업: connCOMMITBEGINROLLBACK에러는 밖으로 전달됨블록이 무사히 끝나면 COMMIT, 중간에 에러가 나면 ROLLBACK이 자동으로 된다.
db.py의tx()가 정확히 이 모양이다. Java의try-with-resources나 Spring의@Transactional과 비슷한 역할이라고 보면 된다. 참고로tx()안의with _lock:도 같은 원리라서, 블록이 끝나면 잠금이 자동으로 풀린다. 다음 Step의registry.py에 나오는with self._lock:도 마찬가지다.
Step 4. 권한 범위와 역할 정하기 (scopes.py)
처음엔 나 혼자 쓰는 서비스였다
처음 버전에는 “사람”이 없었다. 기기만 있었다. 내 폰, 내 워치, 내 안경. 다 만들고 나서 문득 이런 생각이 들었다. “이거 나 혼자만 쓰는 거야? 가족은? 아내도 사용하고 싶으면 어떻하지?”
기기 키를 더 발급하면 가족 폰도 접속은 된다. 그런데 사람 개념이 없으니 문제가 생긴다.
- 승인이 섞인다. 승인 권한이 있는 기기라면 누구의 요청이든 승인할 수 있다. 아내 폰이 내 메일을 승인할 수 있고, 내 워치에 아이 요청이 뜬다.
- 개인 데이터가 섞인다. 메일 권한을 받은 가족 기기가 내 이름으로 메일을 보낼 수 있다. 나중에 옵시디언을 붙이면 내 노트도 읽게 된다.
- 감사 로그에 기기만 남는다. “누가” 했는지 기기 이름으로 짐작해야 한다.
반대로 전등 같은 건 가족이 같이 쓰는 게 자연스럽다. 그래서 기기 위에 사람(사용자) 층을 하나 두기로 했다.
역할과 scope
2편에서 “작업 위험도에 따라 정책을 다르게 한다”고 했다. 그걸 코드로 옮기면 scope가 되고, 사람마다 다른 상한을 두면 역할(role)이 된다.
"""권한 범위(scope), 역할(role), 기기 프리셋.
실제로 쓸 수 있는 권한 = 기기에 준 scope AND 사용자 역할이 허용하는 scope.그래서 아이 계정의 기기에 실수로 넓은 프리셋을 줘도 역할 상한을 넘지 못한다."""
SYSTEM_READ = "system:read" # 내 정보, 상태 조회MAIL_REQUEST = "mail:request" # 메일 발송 요청 (승인 대기열에 올라감)APPROVALS_DECIDE = "approvals:decide" # 내가 결정할 수 있는 요청 조회, 승인, 거절
ALL_SCOPES = {SYSTEM_READ, MAIL_REQUEST, APPROVALS_DECIDE}
# 역할ADMIN = "admin" # 집 주인. 모든 권한, 아이 요청 승인, 키 관리는 NAS CLI로MEMBER = "member" # 어른 가족. 자기 요청은 자기 기기로 승인CHILD = "child" # 아이. 위험한 요청은 관리자가 승인해야 실행
ROLES = (ADMIN, MEMBER, CHILD)
ROLE_SCOPES: dict[str, set[str]] = { ADMIN: set(ALL_SCOPES), MEMBER: {SYSTEM_READ, MAIL_REQUEST, APPROVALS_DECIDE}, CHILD: {SYSTEM_READ, MAIL_REQUEST},}
# 기기 종류별 추천 기본값 (CLI에서 --preset으로 사용)PRESETS: dict[str, set[str]] = { "phone": {SYSTEM_READ, MAIL_REQUEST, APPROVALS_DECIDE}, "watch": {SYSTEM_READ, APPROVALS_DECIDE}, "glasses": {SYSTEM_READ, MAIL_REQUEST}, "mcp": {SYSTEM_READ}, "test": {SYSTEM_READ},}
def effective_scopes(device_scopes, role: str) -> set[str]: """이 기기로 실제로 쓸 수 있는 권한 = 기기에 준 scope AND 역할이 허용하는 scope.
&는 교집합(AND, 양쪽에 모두 있는 것만). 예) 아이(child) 기기에 approvals:decide를 줘도 child 역할 목록에 없으므로 빠진다. 모르는 역할이면 빈 집합(아무 권한 없음). """ return set(device_scopes) & ROLE_SCOPES.get(role, set())핵심은 두 가지다.
첫째, 실제 권한 = 기기 scope AND 역할 상한. 아이 폰에 실수로 phone 프리셋(승인 권한 포함)을 줘도, child 역할의 상한에 승인 권한이 없으니 실제로는 승인할 수 없다. 실수 한 번이 권한 사고로 이어지지 않게 하는 장치다.
둘째, 메일 요청(mail:request)과 승인(approvals:decide)을 분리했다.
| 역할 | 쓸 수 있는 scope | 승인 |
|---|---|---|
admin |
전부 | 본인 요청(다른 기기로) + 아이 요청 |
member |
조회, 메일 요청, 승인 | 본인 요청(다른 기기로) |
child |
조회, 메일 요청 | 못 함. 요청은 관리자가 승인 |
| 프리셋 | 할 수 있는 일 | 이유 |
|---|---|---|
phone |
조회, 메일 요청, 승인 | 메인 앱이자 관리 콘솔 |
watch |
조회, 승인 | 손목에서 탭 한 번으로 승인만 |
glasses |
조회, 메일 요청 | 음성으로 요청은 하되, 스스로 승인은 못 한다 |
mcp |
조회 | AI에게는 일단 보기만 |
test |
조회 | curl 테스트용 |
안경을 잃어버려서 누가 안경 키로 메일을 보내려고 해도, 내 폰이나 워치에서 승인하지 않으면 아무것도 나가지 않는다.
Step 5. 사용자와 기기 저장소 (registry.py)
사람과 기기를 등록하고 보관하는 곳이다. 기기는 반드시 어떤 사람에게 속한다.
"""사용자와 기기 저장소 (data/registry.json).
사람(user)이 있고, 기기(device)는 사람에게 속한다. 한 집에서 쓰는 서비스라활성 사용자 수에 상한(기본 4명)을 둔다.
HMAC은 서버도 같은 비밀값으로 서명을 다시 계산해야 하므로 기기 Secret을 원문으로보관한다. 그래서 파일을 처음부터 권한 600으로 만들고, 이 파일은 데이터 볼륨 밖으로내보내지 않는다."""import jsonimport osimport reimport secretsimport threadingimport timefrom dataclasses import asdict, dataclassfrom pathlib import Path
from .scopes import ADMIN, ALL_SCOPES, ROLES
# 사용자 ID와 기기 이름 규칙. fullmatch()로 쓰기 때문에 문자열 "전체"가 이 모양이어야 한다.# (Access Key 앞부분이 되고 HTTP 헤더로 오가므로 영문 소문자, 숫자, 하이픈만 허용)# [a-z0-9] 첫 글자: 영문 소문자 또는 숫자 (하이픈으로 시작할 수 없다)# [a-z0-9-]{1,23} 나머지 1~23자: 영문 소문자, 숫자, 하이픈# → 전체 2~24자# 통과: dad, kid1, phone-flutter# 거부: -dad (하이픈 시작), Dad (대문자), 내폰 (한글), a (1자), 25자 이상_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,23}")
# 이메일 형식 확인. 실제로 존재하는 주소인지가 아니라 "이메일처럼 생겼는지"만 가볍게 본다.# [^@\s]+ '@'도 공백도 아닌 글자 1개 이상 → 아이디 부분 (dad)# @ '@' 기호 딱 하나# [^@\s]+ '@'도 공백도 아닌 글자 1개 이상 → 도메인 이름 (example)# \. 점(.) 하나. 정규식에서 '.'은 "아무 글자"라는 뜻이라 \ 를 붙여 진짜 점으로 쓴다# [^@\s]+ 점 뒤의 나머지 → (com, co.kr)# ([^...]는 "괄호 안의 글자가 아닌 것", \s는 공백 문자, +는 "1개 이상")# 통과: dad@example.com, mom@mail.co.kr# 거부: dad@example (점 없음), dad@@example.com (@ 두 개), "a b@c.d" (공백)_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
class RegistryError(ValueError): """사용자·기기 등록 규칙을 어겼을 때 내는 오류. 메시지는 그대로 CLI 화면에 보여 준다."""
@dataclassclass User: """가족 한 사람. role은 admin/member/child 중 하나, email은 메일 Reply-To에 쓴다."""
id: str name: str role: str created_at: int email: str | None = None disabled: bool = False
def public(self) -> dict: """API 응답에 내보낼 정보만 고른다 (이메일은 빼고 ID, 이름, 역할만).""" return {"id": self.id, "name": self.name, "role": self.role}
@dataclassclass Device: """사람에게 속한 기기 하나 (폰, 워치, 안경...). access_key가 기기 ID이고 secret이 서명용 비밀값.
폐기해도 지우지 않고 revoked=True로 남긴다. 감사 로그가 가리키는 키를 나중에도 알아볼 수 있게. """
access_key: str user: str name: str secret: str scopes: list[str] created_at: int revoked: bool = False revoked_at: int | None = None
class Registry: """사용자와 기기를 registry.json 파일 하나에 저장하고 꺼내 쓰는 저장소."""
def __init__(self, path: Path, max_users: int = 4): """파일 경로와 최대 사용자 수를 받고, 파일이 있으면 바로 읽어 둔다.""" self.path = path self.max_users = max_users self._lock = threading.Lock() self._users: dict[str, User] = {} self._devices: dict[str, Device] = {} self._mtime: float | None = None self._load()
def _load(self) -> None: """registry.json을 읽어 메모리에 올린다. 파일 수정 시각이 바뀌었을 때만 다시 읽는다.
CLI(docker exec)는 서버와 다른 프로세스라서, CLI로 기기를 추가하면 파일만 바뀐다. 그래서 매번 수정 시각을 보고, 바뀌었으면 서버를 재시작하지 않아도 새 내용을 읽는다. """ if not self.path.exists(): self._users, self._devices, self._mtime = {}, {}, None return mtime = self.path.stat().st_mtime if mtime == self._mtime: return raw = json.loads(self.path.read_text(encoding="utf-8")) self._users = {k: User(**v) for k, v in raw.get("users", {}).items()} self._devices = {k: Device(**v) for k, v in raw.get("devices", {}).items()} self._mtime = mtime
def _save(self) -> None: """메모리 내용을 registry.json에 쓴다.
1. 임시 파일(.tmp)을 처음부터 권한 600(나만 읽고 쓰기)으로 만들어 쓰고 2. os.replace로 원래 파일과 한 번에 바꿔치기한다. 쓰는 도중 서버가 죽어도 원래 파일이 반쯤 쓰인 채로 남지 않는다. """ self.path.parent.mkdir(parents=True, exist_ok=True) tmp = self.path.with_suffix(".tmp") payload = json.dumps( { "users": {k: asdict(v) for k, v in self._users.items()}, "devices": {k: asdict(v) for k, v in self._devices.items()}, }, ensure_ascii=False, indent=2, ) fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(payload) os.replace(tmp, self.path) self._mtime = self.path.stat().st_mtime
# ── 사용자 ─────────────────────────────────────────────
def user(self, user_id: str) -> User | None: """ID로 사용자 한 명을 찾는다. 없으면 None.""" with self._lock: self._load() return self._users.get(user_id)
def users(self) -> list[User]: """모든 사용자 목록 (비활성 사용자 포함).""" with self._lock: self._load() return list(self._users.values())
def add_user(self, user_id: str, name: str, role: str, email: str | None = None) -> User: """사용자를 추가한다. 규칙에 어긋나면 RegistryError.
- ID는 영문 소문자·숫자·하이픈 2~24자, 역할은 admin/member/child 중 하나 - 활성 사용자는 max_users명까지 - 첫 사용자(활성 관리자가 아무도 없을 때)는 admin이어야 한다 """ if not _ID_RE.fullmatch(user_id): raise RegistryError("사용자 ID는 영문 소문자, 숫자, 하이픈으로 2~24자여야 합니다. 예: dad, mom, kid1") if role not in ROLES: raise RegistryError(f"역할은 {', '.join(ROLES)} 중 하나여야 합니다.") if email and not _EMAIL_RE.fullmatch(email): raise RegistryError("이메일 형식이 올바르지 않습니다.") with self._lock: self._load() if user_id in self._users: raise RegistryError("이미 있는 사용자 ID입니다.") active = [u for u in self._users.values() if not u.disabled] if len(active) >= self.max_users: raise RegistryError(f"사용자는 최대 {self.max_users}명까지 등록할 수 있습니다.") if not any(u.role == ADMIN for u in active) and role != ADMIN: raise RegistryError("첫 사용자는 관리자(admin)여야 합니다.") user = User(id=user_id, name=name, role=role, email=email, created_at=int(time.time())) self._users[user_id] = user self._save() return user
def disable_user(self, user_id: str) -> int: """사용자를 비활성화하고 그 사람의 기기 키를 전부 폐기한다. 폐기한 키 개수를 돌려준다.""" with self._lock: self._load() user = self._users.get(user_id) if not user or user.disabled: raise RegistryError("해당 사용자가 없거나 이미 비활성화됐습니다.") others = [u for u in self._users.values() if u.id != user_id and not u.disabled and u.role == ADMIN] if user.role == ADMIN and not others: raise RegistryError("마지막 관리자는 비활성화할 수 없습니다.") user.disabled = True count = self._revoke_where(lambda d: d.user == user_id) self._save() return count
# ── 기기 ───────────────────────────────────────────────
def device(self, access_key: str) -> Device | None: """Access Key로 기기 하나를 찾는다. 없으면 None. (폐기된 기기도 돌려주니 revoked를 따로 확인한다)""" with self._lock: self._load() return self._devices.get(access_key)
def devices(self) -> list[Device]: """모든 기기 목록 (폐기된 기기 포함).""" with self._lock: self._load() return list(self._devices.values())
def add_device(self, user_id: str, name: str, scopes: set[str]) -> Device: """사용자에게 기기 키를 새로 발급한다.
Access Key = "사용자ID-기기이름-무작위8자" (예: dad-phone-1a2b3c4d) Secret = 무작위 32바이트 (secrets.token_urlsafe: 추측할 수 없는 난수) scopes는 저장할 때 그대로 두고, 역할 상한은 요청할 때 effective_scopes()로 깎는다. """ # 이름이 Access Key 앞부분이 되고 HTTP 헤더로 오가므로 영문 소문자, 숫자, 하이픈만 쓴다. if not _ID_RE.fullmatch(name): raise RegistryError("기기 이름은 영문 소문자, 숫자, 하이픈으로 2~24자여야 합니다. 예: phone, glasses") unknown = scopes - ALL_SCOPES if unknown: raise RegistryError(f"알 수 없는 scope: {', '.join(sorted(unknown))}") with self._lock: self._load() user = self._users.get(user_id) if not user or user.disabled: raise RegistryError("해당 사용자가 없거나 비활성화됐습니다.") access_key = f"{user_id}-{name}-{secrets.token_hex(4)}" device = Device( access_key=access_key, user=user_id, name=name, secret=secrets.token_urlsafe(32), scopes=sorted(scopes), created_at=int(time.time()), ) self._devices[access_key] = device self._save() return device
def _revoke_where(self, predicate) -> int: """조건(predicate)에 맞는 기기를 모두 폐기 표시하고, 폐기한 개수를 돌려준다.
predicate는 기기를 받아 True/False를 돌려주는 함수. 예) lambda d: d.user == "kid1" 저장(_save)은 부르는 쪽에서 한다. """ now = int(time.time()) count = 0 for device in self._devices.values(): if not device.revoked and predicate(device): device.revoked = True device.revoked_at = now count += 1 return count
def revoke(self, access_key: str) -> bool: """기기 키 하나를 폐기한다. 폐기했으면 True, 없거나 이미 폐기된 키면 False.""" with self._lock: self._load() count = self._revoke_where(lambda d: d.access_key == access_key) if count: self._save() return bool(count)
def revoke_all(self) -> int: """비상 정지: 모든 기기 키를 한 번에 무효화한다.""" with self._lock: self._load() count = self._revoke_where(lambda d: True) if count: self._save() return count정해 둔 규칙은 이렇다.
- 활성 사용자는 최대 4명(
SECRETARY_MAX_USERS). 한 집 서비스니까. 한 명을 비활성화하면 자리가 생긴다. - 첫 사용자는 반드시 관리자. 관리자가 없으면 아이 요청을 승인할 사람이 없다.
- 마지막 관리자는 비활성화할 수 없다. 같은 이유다.
- 사람을 비활성화하면 그 사람의 기기 키도 전부 폐기된다.
그리고 Secret 보관 이야기. 보통 비밀번호는 해시로만 저장한다. 그런데 HMAC은 서버도 같은 Secret으로 서명을 다시 계산해야 하니, Secret을 원문으로 들고 있어야 한다. 그래서 대신 이렇게 했다.
- 파일을 만들 때부터 권한을
600으로 연다 (os.open(..., 0o600)). 나중에 chmod하는 사이에 다른 사용자가 읽을 틈을 주지 않는다. - 임시 파일에 먼저 쓰고
os.replace로 바꿔치기한다. 쓰다가 서버가 죽어도 파일이 깨지지 않는다. - 사용자 등록과 키 발급은 NAS 안에서만, CLI로만 할 수 있다. 외부 API로는 열지 않았다.(나중에 관리자 페이지라도 만들어야하지 않을까. 당장은 CLI가 편하니까.)
- 비상 정지용
revoke_all()도 만들어 뒀다. 뭔가 이상하면 한 번에 전부 무효화한다.
@dataclass가 뭐지?User와 Device 클래스 위에 @dataclass가 붙어 있다. 필드만 적어 두면 생성자(__init__), 출력용 문자열(__repr__), 비교(__eq__)를 파이썬이 알아서 만들어 주는 데코레이터다. Java의 Lombok @Data나 record와 비슷하다.
@dataclass에 대해 좀 더 알아볼까?
from dataclasses import asdict, dataclass
@dataclassclass User: id: str name: str role: str = "member"
u = User("mom", "엄마")print(u)print(asdict(u))print(u == User("mom", "엄마"))$ python3 dataclass_demo.pyUser(id='mom', name='엄마', role='member'){'id': 'mom', 'name': '엄마', 'role': 'member'}True생성자를 직접 안 썼는데도 User("mom", "엄마")로 만들 수 있고, print하면 내용이 보기 좋게 나오고, 내용이 같으면 ==가 True다. role처럼 기본값을 줄 수도 있다. asdict()는 딕셔너리로 바꿔 주는데, registry.py에서 JSON 파일로 저장할 때 이걸 쓴다.
삽질: 한글 기기 이름
처음에는 기기 이름에 제한이 없었다. 그래서 테스트로 내폰이라는 기기를 만들었더니, 발급된 Access Key가 내폰-d3fbc4a5가 됐다. 그런데 이 키는 HTTP 헤더(X-Access-Key)로 오가야 하고, 문지기가 키 형식을 [A-Za-z0-9_-]로 검사하고 있었다. 결국 발급은 됐는데 절대 인증을 통과할 수 없는 키가 만들어진 것이다. 그래서 사용자 ID와 기기 이름은 영문 소문자, 숫자, 하이픈만 쓰도록 막았다. 한글은 표시 이름("아빠", "첫째")에만 쓴다. Access Key는 사용자-기기-랜덤값 꼴이 된다. dad-glasses-7ebb279d 이런 식으로.
Step 6. 서명 만들기 (signing.py)
2편에서 그림으로 설명했던 그 서명이다. 서버와 클라이언트가 똑같은 방식으로 서명을 만들어야 하니 함수 하나로 모아 뒀다.
"""HMAC 요청 서명. 서버와 클라이언트 예제가 같은 함수를 쓴다.
서명할 문자열 = METHOD \\n PATH[?QUERY] \\n TIMESTAMP \\n NONCE \\n SHA256(body) 16진수서명 = base64url(HMAC-SHA256(secret, 서명할 문자열))"""import base64import hashlibimport hmacimport secretsimport time
HEADER_KEY = "X-Access-Key"HEADER_TIMESTAMP = "X-Timestamp"HEADER_NONCE = "X-Nonce"HEADER_SIGNATURE = "X-Signature"
def canonical_string(method: str, path: str, query: str, timestamp: str, nonce: str, body: bytes) -> str: """서명할 문자열을 만든다. 서버와 클라이언트가 글자 하나까지 똑같이 만들어야 서명이 맞는다.
예) GET /v1/me, 본문 없음 → GET /v1/me 1762300000 3f9c...(nonce) e3b0c442...(빈 본문의 SHA-256) 본문은 통째로 넣지 않고 해시만 넣는다. 본문이 한 글자라도 바뀌면 해시가 달라진다. """ target = f"{path}?{query}" if query else path body_hash = hashlib.sha256(body).hexdigest() return "\n".join([method.upper(), target, timestamp, nonce, body_hash])
def sign(secret: str, canonical: str) -> str: """서명할 문자열을 Secret으로 HMAC-SHA256 서명해 base64url 문자열로 돌려준다.
base64url은 +, / 대신 -, _ 를 써서 HTTP 헤더에 그대로 넣을 수 있고, 끝의 = 채움 글자는 뗀다. """ digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest() return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def build_headers(access_key: str, secret: str, method: str, path: str, query: str = "", body: bytes = b"") -> dict: """요청 하나에 붙일 인증 헤더 4개를 만든다 (테스트와 예제 클라이언트용).
타임스탬프는 지금 시각(초), Nonce는 매번 새 무작위 값이라 같은 요청도 매번 서명이 달라진다. """ timestamp = str(int(time.time())) nonce = secrets.token_hex(16) canonical = canonical_string(method, path, query, timestamp, nonce, body) return { HEADER_KEY: access_key, HEADER_TIMESTAMP: timestamp, HEADER_NONCE: nonce, HEADER_SIGNATURE: sign(secret, canonical), }서명할 문자열에는 다섯 가지가 들어간다.
- METHOD: GET인지 POST인지
- PATH(+쿼리): 어디로 보내는지
- TIMESTAMP: 언제 보냈는지
- NONCE: 매번 새로 만드는 일회용 값
- 본문의 SHA-256 해시: 무엇을 보냈는지
본문 해시가 들어가 있어서, 누가 중간에 메일 본문을 슬쩍 바꾸면 서명이 맞지 않는다. 경로가 들어가 있어서, “메일 요청” 서명을 떼다가 “승인” 요청에 붙일 수도 없다.
Step 7. 문지기 (guard.py)
이번 편의 핵심이다. /v1로 들어오는 모든 요청은 이 문지기를 통과해야 한다.
"""모든 /v1 요청 앞에서 도는 인증 의존성.
확인 순서: 1. Cloudflare Access를 거쳐 왔는지 (cloudflared가 붙이는 JWT 헤더) 2. 헤더 형식, 본문 크기 3. 키 잠금 여부 (실패가 몰린 키는 잠시 차단) 4. 기기 존재 여부와 폐기 여부, 기기 주인(사용자)이 활성 상태인지 5. 타임스탬프 허용 범위 6. 서명 일치 (상수 시간 비교) 7. Nonce 재사용 여부 8. scope (기기 scope AND 사용자 역할이 허용하는 scope)
실패 원인은 감사 로그에만 남기고, 응답은 항상 같은 401 한 가지로 돌려준다.어디서 막혔는지 알려 주면 공격자에게 힌트가 되기 때문이다."""import hmacimport reimport timefrom dataclasses import dataclass
from fastapi import HTTPException, Request, status
from .registry import Device, Userfrom .scopes import effective_scopesfrom .signing import ( HEADER_KEY, HEADER_NONCE, HEADER_SIGNATURE, HEADER_TIMESTAMP, canonical_string, sign,)
_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{3,64}$")_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")_TS_RE = re.compile(r"^\d{9,11}$")
UNAUTHORIZED = HTTPException(status.HTTP_401_UNAUTHORIZED, detail="인증에 실패했습니다.")FORBIDDEN = HTTPException(status.HTTP_403_FORBIDDEN, detail="권한이 없습니다.")
@dataclassclass Caller: """인증을 통과한 호출자. 라우터 함수는 이걸 받아 누가(user), 어떤 기기로(device), 무엇을 할 수 있는지(scopes) 안다."""
user: User device: Device scopes: set[str] ip: str | None
def _client_ip(request: Request) -> str | None: """요청한 쪽의 실제 IP를 찾는다. 감사 로그에 남기는 용도.""" # Cloudflare를 거친 요청은 이 헤더에 실제 클라이언트 IP가 있다. return request.headers.get("Cf-Connecting-Ip") or (request.client.host if request.client else None)
async def authenticate(request: Request) -> Caller: """요청 헤더의 서명을 검사해 호출자(Caller)를 돌려준다. 하나라도 틀리면 401.
검사 순서는 이 파일 맨 위 설명 참고. 비용이 싼 검사(헤더 형식)를 먼저, 비싼 검사(DB 조회, 서명 계산)를 나중에 한다. """ state = request.app.state settings = state.settings audit = state.audit ip = _client_ip(request)
def fail(reason: str, key: str | None = None) -> HTTPException: """인증 실패를 감사 로그와 실패 기록 테이블에 남기고, 돌려줄 401 예외를 만든다.
부르는 쪽에서 raise fail("이유") 로 쓴다. 이유는 로그에만 남고 응답에는 나가지 않는다. 키 형식이 정상일 때만 실패 기록을 쌓는다(이상한 값으로 테이블을 채우지 못하게). """ audit.write("auth.fail", device=key, ip=ip, reason=reason, path=request.url.path) if key and _KEY_RE.match(key): with state.db.tx() as c: c.execute("INSERT INTO auth_failures (access_key, at) VALUES (?, ?)", (key, int(time.time()))) return UNAUTHORIZED
if settings.require_cf_access and not request.headers.get("Cf-Access-Jwt-Assertion"): raise fail("no_cf_access")
key = request.headers.get(HEADER_KEY, "") ts = request.headers.get(HEADER_TIMESTAMP, "") nonce = request.headers.get(HEADER_NONCE, "") signature = request.headers.get(HEADER_SIGNATURE, "") if not (_KEY_RE.match(key) and _TS_RE.match(ts) and _NONCE_RE.match(nonce) and signature): raise fail("bad_headers", key or None)
body = await request.body() if len(body) > settings.max_body_bytes: raise fail("body_too_large", key)
now = int(time.time()) with state.db.tx() as c: c.execute("DELETE FROM auth_failures WHERE at < ?", (now - settings.lockout_window_seconds,)) failures = c.execute( "SELECT COUNT(*) FROM auth_failures WHERE access_key = ? AND at > ?", (key, now - settings.lockout_window_seconds), ).fetchone()[0] if failures >= settings.lockout_threshold: audit.write("auth.locked", device=key, ip=ip, path=request.url.path) raise UNAUTHORIZED
device = state.registry.device(key) if device is None: raise fail("unknown_key", key) if device.revoked: raise fail("revoked_key", key) user = state.registry.user(device.user) if user is None or user.disabled: raise fail("user_disabled", key)
if abs(now - int(ts)) > settings.clock_skew_seconds: raise fail("timestamp_skew", key)
canonical = canonical_string(request.method, request.url.path, request.url.query, ts, nonce, body) expected = sign(device.secret, canonical) if not hmac.compare_digest(expected, signature): raise fail("bad_signature", key)
# Nonce는 타임스탬프 허용 범위의 두 배 동안 기억한다. 그 뒤에는 타임스탬프 검사에서 걸린다. with state.db.tx() as c: c.execute("DELETE FROM nonces WHERE expires_at < ?", (now,)) exists = c.execute( "SELECT 1 FROM nonces WHERE access_key = ? AND nonce = ?", (key, nonce) ).fetchone() if not exists: c.execute( "INSERT INTO nonces (access_key, nonce, expires_at) VALUES (?, ?, ?)", (key, nonce, now + settings.clock_skew_seconds * 2), ) if exists: raise fail("nonce_replay", key)
return Caller(user=user, device=device, scopes=effective_scopes(device.scopes, user.role), ip=ip)
def require(scope: str): """scope 하나를 받아 "이 권한이 있어야 통과"하는 의존성 함수를 만들어 돌려준다.
@router.get("/me") async def me(caller: Caller = Depends(require(SYSTEM_READ))):
인증(401)을 먼저 하고, scope가 없으면 403. scope마다 검사 함수를 찍어 내는 함수다. """ async def dependency(request: Request) -> Caller: """FastAPI가 요청마다 실제로 부르는 함수. 인증 후 scope를 확인한다.""" caller = await authenticate(request) if scope not in caller.scopes: request.app.state.audit.write( "auth.forbidden", user=caller.user.id, device=caller.device.access_key, ip=caller.ip, scope=scope, path=request.url.path, ) raise FORBIDDEN return caller
return dependency검사 순서를 그림으로 그리면 이렇다.
몇 가지 짚고 넘어가자.
- 실패 이유는 응답에 안 쓴다. 서명이 틀렸든, 시간이 지났든, 키가 폐기됐든 응답은 똑같이
401 인증에 실패했습니다.하나다. 진짜 이유는 감사 로그에만 남긴다. 시큐어 코딩 글에서 “오류 페이지는 아무것도 말하지 않게”라고 했던 그 얘기다. 어디서 막혔는지 알려 주면 공격자에게 힌트가 된다. - 서명 비교는
hmac.compare_digest로 한다. 그냥==로 비교하면 앞 글자부터 비교하다가 다른 글자가 나오는 순간 멈추기 때문에, 응답 시간 차이로 서명을 한 글자씩 맞혀 가는 타이밍 공격이 가능해진다. - Nonce는 120초 동안만 기억한다. 타임스탬프 허용 범위(±60초)의 두 배다. 그보다 오래된 요청은 어차피 타임스탬프 검사에서 걸린다. 그래서 Nonce 테이블이 끝없이 커지지 않는다.
- 기기 주인도 확인한다. 기기 키가 살아 있어도, 그 사람이 비활성화됐으면 막힌다. 그리고 여기서 실제 권한(기기 scope AND 역할 상한)을 계산해서
Caller에 담아 둔다. - Cloudflare Access 헤더도 확인한다. cloudflared가 JWT 검증을 이미 하지만, 혹시 누가 NAS 내부망에서 직접 찔러도 이 헤더가 없으면 막힌다. 겹겹이 막는 거다.

async와 await가 뭐지?async def authenticate(...), body = await request.body(). 함수 앞에 async, 호출 앞에 await가 붙어 있다.
이건 “기다리는 동안 다른 일을 하러 가도 된다”는 표시다. 네트워크로 요청 본문을 받거나, 외부 API의 응답을 기다리는 시간은 CPU가 할 일 없이 노는 시간이다. await를 만나면 파이썬은 그 일이 끝날 때까지 멈춰 있지 않고, 그사이 다른 요청을 처리하러 간다.
async와 await에 대해 좀 더 알아볼까?
import asyncioimport time
async def work(name: str, seconds: float) -> str: await asyncio.sleep(seconds) # 기다리는 동안 다른 일을 하러 간다 return f"{name} 완료"
async def main(): start = time.perf_counter() results = await asyncio.gather(work("메일", 1), work("노트", 1), work("전등", 1)) print(results, f"{time.perf_counter() - start:.1f}초")
asyncio.run(main())$ python3 async_demo.py['메일 완료', '노트 완료', '전등 완료'] 1.0초1초씩 걸리는 일 세 개를 했는데 전부 합쳐 1초다. 하나씩 기다렸다면 3초가 걸렸을 거다. 세 개가 동시에 기다렸기 때문이다.
규칙은 두 가지만 기억하면 된다. await는 async def 함수 안에서만 쓸 수 있고, async def 함수는 await로 불러야 결과가 나온다. FastAPI는 async def로 만든 함수를 알아서 이렇게 돌려 준다.
Step 8. 첫 API: 나 누구야? (routers/system.py)
문지기를 만들었으니 API를 하나 열어 보자. 제일 간단한 “나 누구야?” API다.
from fastapi import APIRouter, Depends
from ..auth.guard import Caller, requirefrom ..auth.scopes import SYSTEM_READ
router = APIRouter(prefix="/v1", tags=["system"])
@router.get("/me")async def me(caller: Caller = Depends(require(SYSTEM_READ))): """GET /v1/me : 지금 이 키가 누구의 어떤 기기이고, 무엇을 할 수 있는지 돌려준다.
새 기기를 설정하고 처음 호출해 보는 용도. 200이 오면 서명과 Cloudflare Access 설정이 모두 맞은 것이다. """ d = caller.device return { "user": caller.user.public(), "device": {"access_key": d.access_key, "name": d.name}, "scopes": sorted(caller.scopes), }Depends(require(SYSTEM_READ)) 한 줄이면, 문지기 검사와 scope 검사를 다 통과한 요청만 이 함수에 들어온다. 응답에는 내가 누구인지(사용자), 어떤 기기인지, 그리고 역할 상한까지 반영한 실제 권한이 나온다.
메일 요청과 승인 API는 두 번째 이야기에서 만든다.
Depends가 뭐지?caller: Caller = Depends(require(SYSTEM_READ)). 함수 인자 자리에 Depends(...)가 들어가 있다.
Depends는 FastAPI의 의존성 주입이다. “이 API를 실행하기 전에 이 함수를 먼저 돌리고, 그 결과를 인자로 넣어 줘”라는 뜻이다. 먼저 돌린 함수가 에러를 던지면, API 함수는 아예 실행되지 않는다.
from fastapi import Depends, FastAPI, Header, HTTPExceptionfrom fastapi.testclient import TestClient
app = FastAPI()
def who_are_you(x_name: str = Header("")) -> str: if not x_name: raise HTTPException(401, "누구세요?") return x_name
@app.get("/hello")def hello(name: str = Depends(who_are_you)): return {"hello": name}
client = TestClient(app)print(client.get("/hello").status_code)print(client.get("/hello", headers={"X-Name": "dad"}).json())$ python3 depends_demo.py401{'hello': 'dad'}
X-Name헤더가 없으면who_are_you가 401을 던지고hello는 실행조차 안 된다. 있으면 그 값이name으로 들어온다. Secretary에서는require(...)가 이 역할을 한다. 문지기 검사와 scope 검사를 통과해야만 API 함수에Caller가 들어온다. 그래서 API마다 인증 코드를 복사해서 붙일 필요가 없다. Spring으로 치면 Security 필터와@AuthenticationPrincipal을 합친 느낌이다.
Step 9. 앱 조립 (main.py)
import loggingimport uuidfrom contextlib import asynccontextmanager
from fastapi import FastAPI, Requestfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom starlette.exceptions import HTTPException
from .audit import AuditLogfrom .auth.registry import Registryfrom .config import Settings, get_settingsfrom .db import Databasefrom .modules.approvals import ApprovalQueuefrom .modules.mail import MailSenderfrom .routers import approvals, mail, system
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")logger = logging.getLogger("secretary")
def create_app(settings: Settings | None = None, mail_sender=None) -> FastAPI: """FastAPI 앱을 만든다. 테스트에서는 settings와 가짜 mail_sender를 넣어 따로 만든다.
운영 서버는 맨 아래 app = create_app() 을 uvicorn이 불러 쓴다. """ settings = settings or get_settings()
@asynccontextmanager async def lifespan(app: FastAPI): """서버가 켜질 때(yield 전) 필요한 객체를 만들어 app.state에 두고, 꺼질 때(yield 뒤) 정리한다.
라우터는 request.app.state.db 처럼 꺼내 쓴다. """ app.state.settings = settings app.state.db = Database(settings.db_path) app.state.registry = Registry(settings.registry_path, settings.max_users) app.state.audit = AuditLog(settings.audit_path) app.state.approvals = ApprovalQueue(app.state.db, settings.approval_ttl_seconds) app.state.mail = mail_sender or MailSender(settings.ses_region, settings.ses_from) app.state.audit.write("server.start") yield app.state.db.close()
# 운영에서는 문서 페이지를 열지 않는다. 스펙이 필요하면 로컬에서 따로 뽑는다. app = FastAPI(title="Secretary", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http") async def request_id(request: Request, call_next): """모든 응답에 X-Request-Id를 붙이는 미들웨어.
예상 못 한 오류가 나면 자세한 내용은 서버 로그에만 남기고, 클라이언트에는 같은 ID가 붙은 500만 돌려준다. 사용자가 ID를 알려 주면 로그에서 그 요청을 바로 찾을 수 있다. """ rid = uuid.uuid4().hex[:12] try: response = await call_next(request) except Exception: logger.exception("unhandled error rid=%s path=%s", rid, request.url.path) response = JSONResponse({"code": "INTERNAL_ERROR", "message": "일시적인 오류가 발생했습니다."}, 500) response.headers["X-Request-Id"] = rid return response
@app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): """HTTP 오류(401, 403, 404 ...)를 {code, message} 모양의 JSON으로 통일한다.""" return JSONResponse({"code": f"HTTP_{exc.status_code}", "message": exc.detail}, exc.status_code)
@app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError): """요청 형식 오류(422)를 {code, message, fields} 모양으로 바꾼다.""" # 어떤 필드가 왜 틀렸는지만 알려 주고, 입력값 자체는 돌려주지 않는다. fields = [".".join(str(p) for p in e["loc"][1:]) for e in exc.errors()] return JSONResponse({"code": "INVALID_REQUEST", "message": "요청 형식이 올바르지 않습니다.", "fields": fields}, 422)
@app.get("/healthz", include_in_schema=False) async def healthz(): """GET /healthz : 서버가 살아 있는지. 인증 없이 열려 있고, 컨테이너 상태 확인(healthcheck)이 부른다.""" return {"ok": True}
app.include_router(system.router) app.include_router(mail.router) app.include_router(approvals.router) return app
app = create_app()시큐어 코딩 글에서 했던 이야기를 여기 다 넣었다.
- API 문서 페이지를 끈다. FastAPI는 기본으로
/docs에 Swagger 화면을 띄워 준다. 편하지만, 밖에서 보이는 서버라면 공격자에게 지도를 건네주는 셈이다.docs_url=None,openapi_url=None. - 오류 응답 형식을 통일한다. 무슨 오류든
{"code": "...", "message": "..."}하나로 나간다. 예상하지 못한 예외는 스택 트레이스를 서버 로그에만 남기고, 응답에는 “일시적인 오류가 발생했습니다”만. - 입력값 검증 오류에도 입력값을 되돌려주지 않는다. 어떤 필드가 틀렸는지만 알려 준다.
- 요청마다
X-Request-Id를 붙인다. 문제가 생기면 이 ID로 서버 로그를 바로 찾는다.
@asynccontextmanager와 lifespan이 뭐지?main.py 위쪽의 lifespan 함수. 앞에서 본 @contextmanager의 async 버전이다. 생김새도 똑같이 yield가 가운데 있다.
FastAPI에서는 이걸 서버의 일생에 쓴다. yield 앞은 서버가 켜질 때 한 번, yield 뒤는 서버가 꺼질 때 한 번 실행된다. 그 사이가 서버가 요청을 받는 시간이다.
from contextlib import asynccontextmanager
from fastapi import FastAPIfrom fastapi.testclient import TestClient
@asynccontextmanagerasync def lifespan(app: FastAPI): print("서버 시작: DB 연결") yield print("서버 종료: DB 닫기")
app = FastAPI(lifespan=lifespan)
@app.get("/")def root(): print("요청 처리") return {}
with TestClient(app) as client: client.get("/")$ python3 lifespan_demo.py서버 시작: DB 연결요청 처리서버 종료: DB 닫기Secretary는 서버가 켜질 때 DB 연결, 사용자·기기 저장소, 감사 로그, 승인 대기열, 메일 발송기를 한 번씩 만들어서
app.state에 넣어 두고, 요청마다 그걸 꺼내 쓴다. 꺼질 때는 DB 연결을 닫는다. Spring의@PostConstruct와@PreDestroy를 한 함수에 합친 셈이다.
Step 10. 가족 등록 CLI (cli.py)
사용자 등록, 키 발급과 폐기는 외부 API로 열지 않았다. NAS 안에서 docker exec로만 할 수 있다. 당장은 관리자 페이지가 아직 개발 전 이라서 CLI(Command Line Interface)로만 명령하게 만들었다.
"""사용자·기기 관리 CLI. NAS 안에서만 실행한다(외부 API로는 키를 발급할 수 없다).
# 사용자 (최대 SECRETARY_MAX_USERS명, 기본 4명. 첫 사용자는 admin) docker exec -it secretary python -m app.cli user add dad "아빠" --role admin --email dad@example.com docker exec -it secretary python -m app.cli user add kid1 "첫째" --role child docker exec -it secretary python -m app.cli user list docker exec -it secretary python -m app.cli user disable kid1 # 그 사람의 기기 키도 전부 폐기
# 기기 docker exec -it secretary python -m app.cli device add dad phone --preset phone docker exec -it secretary python -m app.cli device list docker exec -it secretary python -m app.cli device revoke <access_key> docker exec -it secretary python -m app.cli device revoke-all # 비상 정지"""import argparseimport sysfrom datetime import datetime
from .audit import AuditLogfrom .auth.registry import Registry, RegistryErrorfrom .auth.scopes import ALL_SCOPES, PRESETS, ROLES, effective_scopesfrom .config import get_settings
def _fmt(ts: int | None) -> str: """유닉스 시각(초)을 '2026-11-04 21:30' 같은 글자로 바꾼다. 값이 없으면 '-'.""" return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "-"
def build_parser() -> argparse.ArgumentParser: """명령어 구조를 정의한다. user/device 아래에 add, list ... 같은 하위 명령이 붙는다.
argparse가 잘못된 입력을 걸러 주고, -h 도움말도 자동으로 만든다. """ parser = argparse.ArgumentParser(prog="secretary") sub = parser.add_subparsers(dest="cmd", required=True)
usr = sub.add_parser("user", help="사용자 관리").add_subparsers(dest="action", required=True) ua = usr.add_parser("add", help="사용자 추가") ua.add_argument("user_id", help="영문 소문자 ID. 예: dad, mom, kid1") ua.add_argument("name", help="표시 이름. 메일 보낸 사람 이름으로도 쓰인다") ua.add_argument("--role", choices=ROLES, required=True) ua.add_argument("--email", help="본인 이메일. 메일 Reply-To, 본인에게 보내기 허용에 쓰인다") usr.add_parser("list", help="사용자 목록") ud = usr.add_parser("disable", help="사용자 비활성화 (기기 키도 전부 폐기)") ud.add_argument("user_id")
dev = sub.add_parser("device", help="기기 관리").add_subparsers(dest="action", required=True) da = dev.add_parser("add", help="기기 키 발급") da.add_argument("user_id") da.add_argument("name", help="영문 소문자 기기 이름. 예: phone, watch, glasses") da.add_argument("--preset", choices=sorted(PRESETS)) da.add_argument("--scope", action="append", default=[], help=f"직접 지정: {', '.join(sorted(ALL_SCOPES))}") dev.add_parser("list", help="기기 목록") dr = dev.add_parser("revoke", help="기기 키 폐기") dr.add_argument("access_key") dev.add_parser("revoke-all", help="모든 기기 키 폐기 (비상 정지)") return parser
def main(argv: list[str] | None = None) -> int: """CLI 시작점. 명령을 해석해 _user 또는 _device로 넘긴다.
돌려주는 값은 종료 코드: 0 성공, 1 대상 없음, 2 입력 오류(규칙 위반). """ args = build_parser().parse_args(argv) settings = get_settings() reg = Registry(settings.registry_path, settings.max_users) audit = AuditLog(settings.audit_path)
try: if args.cmd == "user": return _user(args, reg, audit) return _device(args, reg, audit) except RegistryError as e: print(e, file=sys.stderr) return 2
def _user(args, reg: Registry, audit: AuditLog) -> int: """user add / list / disable 을 처리한다. 바꾼 내용은 감사 로그에 via=cli로 남긴다.""" if args.action == "add": user = reg.add_user(args.user_id, args.name, args.role, args.email) audit.write("user.add", user=user.id, via="cli", role=user.role) print(f"사용자를 추가했습니다: {user.id} ({user.name}, {user.role})") return 0 if args.action == "list": users = reg.users() active = sum(1 for u in users if not u.disabled) print(f"활성 사용자 {active}/{reg.max_users}명") for u in users: state = "비활성" if u.disabled else "활성" print(f" {u.id:<12} {u.name:<10} {u.role:<7} {state:<4} {u.email or '-'}") return 0 if args.action == "disable": count = reg.disable_user(args.user_id) audit.write("user.disable", user=args.user_id, via="cli", revoked_devices=count) print(f"비활성화했습니다. 기기 키 {count}개를 폐기했습니다.") return 0 return 1
def _device(args, reg: Registry, audit: AuditLog) -> int: """device add / list / revoke / revoke-all 을 처리한다.
add는 Secret을 이때 한 번만 화면에 보여 준다. 역할 상한 때문에 빠지는 scope가 있으면 같이 알려 준다. """ if args.action == "add": scopes = set(PRESETS.get(args.preset, set())) | set(args.scope) if not scopes: print("--preset 또는 --scope 중 하나는 지정해야 합니다.", file=sys.stderr) return 2 device = reg.add_device(args.user_id, args.name, scopes) user = reg.user(args.user_id) effective = effective_scopes(device.scopes, user.role) audit.write("device.add", user=user.id, device=device.access_key, via="cli", scopes=device.scopes) print("기기 키를 발급했습니다. Secret은 지금 한 번만 보여 줍니다.\n") print(f" 사용자 : {user.id} ({user.name}, {user.role})") print(f" Access Key : {device.access_key}") print(f" Secret : {device.secret}") print(f" Scopes : {', '.join(sorted(effective))}") dropped = set(device.scopes) - effective if dropped: print(f" (역할 {user.role} 상한 때문에 실제로는 쓸 수 없는 scope: {', '.join(sorted(dropped))})") return 0 if args.action == "list": for d in reg.devices(): state = "폐기됨" if d.revoked else "사용 중" print(f"{d.access_key:<32} {d.user:<10} {state:<5} 발급 {_fmt(d.created_at)} {', '.join(d.scopes)}") return 0 if args.action == "revoke": ok = reg.revoke(args.access_key) if ok: audit.write("device.revoke", device=args.access_key, via="cli") print("폐기했습니다." if ok else "해당 키가 없거나 이미 폐기됐습니다.") return 0 if ok else 1 if args.action == "revoke-all": count = reg.revoke_all() audit.write("device.revoke_all", via="cli", count=count) print(f"{count}개 키를 폐기했습니다.") return 0 return 1
if __name__ == "__main__": sys.exit(main())예를 들어 가족 구성원들을 등록하면 이렇게 된다.
$ docker exec -it secretary python -m app.cli user add mom "엄마" --role member첫 사용자는 관리자(admin)여야 합니다.
$ docker exec -it secretary python -m app.cli user add dad "아빠" --role admin --email dad@example.com사용자를 추가했습니다: dad (아빠, admin)
$ docker exec -it secretary python -m app.cli user add kid1 "첫째" --role child사용자를 추가했습니다: kid1 (첫째, child)
$ docker exec -it secretary python -m app.cli device add kid1 phone --preset phone기기 키를 발급했습니다. Secret은 지금 한 번만 보여 줍니다.
사용자 : kid1 (첫째, child) Access Key : kid1-phone-1b207d04 Secret : **************************************** Scopes : mail:request, system:read (역할 child 상한 때문에 실제로는 쓸 수 없는 scope: approvals:decide)
$ docker exec -it secretary python -m app.cli user list활성 사용자 2/4명 dad 아빠 admin 활성 dad@example.com kid1 첫째 child 활성 -
$ docker exec -it secretary python -m app.cli user disable dad마지막 관리자는 비활성화할 수 없습니다.관리자보다 먼저 엄마를 등록하려고 하면 막히고, 아이 폰에 phone 프리셋을 주면 승인 권한은 역할 상한 때문에 잘렸다고 알려 준다. 마지막 관리자는 비활성화할 수 없다.
Cloudflare 서비스 토큰처럼, Secret은 발급할 때 한 번만 보여 준다. 잘 적어 두자.
Step 11. 테스트 (인증)
보안 코드는 “잘 되는지”보다 “잘 막히는지”를 테스트해야 한다. 2편에서도 강조했던 얘기다. 그래서 테스트 대부분이 막히는 경우다.
class FakeSES: def __init__(self): self.sent = []
def send(self, msg): self.sent.append(msg) return "fake-message-id"
@pytest.fixturedef env(tmp_path): settings = Settings( data_dir=tmp_path, require_cf_access=True, ses_from="Secretary <s@example.com>", mail_allowed_recipients="@family.example", lockout_threshold=5, ) reg = Registry(settings.registry_path, settings.max_users) reg.add_user("dad", "아빠", ADMIN, "dad@example.com") reg.add_user("mom", "엄마", MEMBER, "mom@example.com") reg.add_user("kid", "첫째", CHILD) d = { "dad_phone": reg.add_device("dad", "phone", PRESETS["phone"]), "dad_watch": reg.add_device("dad", "watch", PRESETS["watch"]), "dad_glasses": reg.add_device("dad", "glasses", PRESETS["glasses"]), "mom_phone": reg.add_device("mom", "phone", PRESETS["phone"]), "mom_watch": reg.add_device("mom", "watch", PRESETS["watch"]), "kid_phone": reg.add_device("kid", "phone", PRESETS["phone"]), # 역할 상한으로 승인 권한은 잘린다 } ses = FakeSES() app = create_app(settings, mail_sender=ses) with TestClient(app) as client: yield client, settings, reg, d, ses
def call(client, device, method, path, body=None, query="", headers=None): raw = json.dumps(body).encode() if body is not None else b"" h = {**CF, **build_headers(device.access_key, device.secret, method, path, query, raw)} if body is not None: h["Content-Type"] = "application/json" h.update(headers or {}) url = f"{path}?{query}" if query else path return client.request(method, url, content=raw, headers=h)
def request_mail(client, device, to="grandma@family.example", subject="퇴근", body="지금 출발해요"): return call(client, device, "POST", "/v1/mail", {"to": [to], "subject": subject, "body": body})
def pending_ids(client, device): r = call(client, device, "GET", "/v1/approvals", query="status=pending") assert r.status_code == 200 return [a["id"] for a in r.json()["items"]]
# ── 인증 ─────────────────────────────────────────────────────FakeSES는 진짜 SES 대신 보낸 메일을 리스트에 쌓아 두는 가짜다. 테스트할 때마다 진짜 메일이 나가면 곤란하니까. 메일 쪽 테스트는 두 번째 이야기에서 본다. call()은 서명을 붙여서 요청을 보내는 도우미 함수다.
테스트에는 세 식구가 등장한다. 관리자 아빠(dad), 어른 가족 엄마(mom), 아이 첫째(kid). 아이 폰에는 일부러 phone 프리셋을 줘서, 역할 상한이 제대로 권한을 잘라 내는지 확인한다.
재전송 공격 테스트는 이렇게 생겼다. 똑같은 헤더로 두 번 보내면 두 번째는 막혀야 한다.
def test_replay_rejected(env): client, _, _, d, _ = env h = {**CF, **build_headers(d["dad_phone"].access_key, d["dad_phone"].secret, "GET", "/v1/me")} assert client.get("/v1/me", headers=h).status_code == 200 assert client.get("/v1/me", headers=h).status_code == 401인증과 사용자 관리 쪽 테스트는 이렇다.
| 테스트 | 확인하는 것 |
|---|---|
test_healthz_is_open |
헬스체크는 인증 없이 열려 있다 |
test_signed_request_succeeds |
올바른 서명은 통과, 누구인지 알려 준다 |
test_requires_cloudflare_access_header |
Cloudflare를 안 거친 요청은 401 |
test_bad_signature_rejected |
틀린 Secret은 401 |
test_tampered_body_rejected |
본문을 바꿔치기하면 401 |
test_replay_rejected |
같은 요청을 다시 보내면 401 |
test_old_timestamp_rejected |
1시간 전 서명은 401 |
test_revoked_key_rejected |
폐기된 키는 401 |
test_lockout_after_repeated_failures |
실패가 몰리면 맞는 서명도 잠시 401 |
test_disabled_user_devices_rejected |
비활성화된 사람의 기기는 401 |
test_device_scope_enforced |
워치는 메일 요청 불가 (403) |
test_role_caps_device_scopes |
아이 폰은 프리셋과 상관없이 승인 불가 |
test_first_user_must_be_admin |
첫 사용자는 관리자 |
test_max_users |
5번째 사용자는 등록 불가, 비활성화하면 자리 생김 |
test_cannot_disable_last_admin |
마지막 관리자는 비활성화 불가 |
test_disable_user_revokes_devices |
사람을 비활성화하면 기기 키도 폐기 |
test_names_must_be_ascii |
한글 ID와 기기 이름은 거부 |
test_registry_file_is_private |
저장소 파일 권한 600 |
test_client_signature_matches_server |
예제 클라이언트 서명 = 서버 서명 |
$ pytest -q............................ [100%]28 passed in 0.91s전체 28개가 전부 통과. 이 중 19개가 이번 이야기의 인증 쪽이고, 나머지 9개는 메일과 승인이라 두 번째 이야기에서 본다.
Step 12. 예제 클라이언트로 호출해 보기
다른 클라이언트(Flutter, G2, Zepp)를 만들 때 참고할 수 있게, 표준 라이브러리만으로 서명해서 호출하는 예제를 만들었다. 설치할 게 없어서 Mac에서 바로 돌릴 수 있다.
#!/usr/bin/env python3"""Secretary 호출 예제 클라이언트. 표준 라이브러리만 쓴다.
다른 클라이언트(Flutter, G2, Zepp)도 이 파일의 sign_headers()와 같은 방식으로 서명하면 된다.
환경 변수: SECRETARY_URL https://api.example.house CF_CLIENT_ID Cloudflare Access 서비스 토큰 ID CF_CLIENT_SECRET Cloudflare Access 서비스 토큰 Secret SECRETARY_ACCESS_KEY Secretary 기기 키 SECRETARY_SECRET Secretary 기기 Secret
사용 예: python3 secretary_client.py GET /v1/me python3 secretary_client.py POST /v1/mail '{"to":["me@example.com"],"subject":"테스트","body":"안녕"}' python3 secretary_client.py GET "/v1/approvals?status=pending" python3 secretary_client.py POST /v1/approvals/<id>/approve"""import base64import hashlibimport hmacimport jsonimport osimport secretsimport sysimport timeimport urllib.errorimport urllib.parseimport urllib.request
def sign_headers(access_key: str, secret: str, method: str, path: str, query: str, body: bytes) -> dict: """요청 하나에 붙일 서명 헤더 4개를 만든다. 서버의 app/auth/signing.py와 똑같은 방식.
다른 언어로 클라이언트를 만들 때 이 함수만 옮겨 적으면 된다. """ timestamp = str(int(time.time())) nonce = secrets.token_hex(16) target = f"{path}?{query}" if query else path canonical = "\n".join([method.upper(), target, timestamp, nonce, hashlib.sha256(body).hexdigest()]) digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest() return { "X-Access-Key": access_key, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.urlsafe_b64encode(digest).decode().rstrip("="), }
def main() -> int: """명령줄 인자로 받은 요청을 보내고, 상태 코드와 응답 JSON을 보기 좋게 출력한다.
Cloudflare Access 서비스 토큰 헤더 + Secretary 서명 헤더를 둘 다 붙인다. 400 이상이면 종료 코드 1. """ if len(sys.argv) < 3: print(__doc__) return 2 method, target = sys.argv[1].upper(), sys.argv[2] body = sys.argv[3].encode() if len(sys.argv) > 3 else b"" parsed = urllib.parse.urlsplit(target)
env = os.environ headers = { "CF-Access-Client-Id": env["CF_CLIENT_ID"], "CF-Access-Client-Secret": env["CF_CLIENT_SECRET"], **sign_headers(env["SECRETARY_ACCESS_KEY"], env["SECRETARY_SECRET"], method, parsed.path, parsed.query, body), } if body: headers["Content-Type"] = "application/json"
req = urllib.request.Request(env["SECRETARY_URL"].rstrip("/") + target, data=body or None, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=10) as res: status, payload = res.status, res.read() except urllib.error.HTTPError as e: status, payload = e.code, e.read() print(status) try: print(json.dumps(json.loads(payload), ensure_ascii=False, indent=2)) except ValueError: print(payload.decode(errors="replace")[:500]) return 0 if status < 400 else 1
if __name__ == "__main__": sys.exit(main())로컬에서 서버를 띄우고, 아빠의 안경(dad glasses)으로 발급한 키로 호출해 봤다.
$ python3 secretary_client.py GET /v1/me200{ "user": {"id": "dad", "name": "아빠", "role": "admin"}, "device": {"access_key": "dad-glasses-7ebb279d", "name": "glasses"}, "scopes": ["mail:request", "system:read"]}
$ python3 secretary_client.py GET "/v1/approvals?status=pending"403{"code": "HTTP_403", "message": "권한이 없습니다."}
$ SECRETARY_SECRET=wrong python3 secretary_client.py GET /v1/me401{"code": "HTTP_401", "message": "인증에 실패했습니다."}서명이 맞으면 내가 누구인지와 실제 권한을 돌려준다(200). 아빠는 관리자지만 안경 기기에는 승인 권한이 없으니 승인 대기열은 볼 수 없다(403). Secret이 틀리면 왜 틀렸는지 말해 주지 않는다(401). 그리고 응답 헤더를 보면 Server 헤더가 없다. uvicorn을 --no-server-header로 띄웠기 때문이다. 시큐어 인프라 글에서 얘기한 “서버의 이름표는 떼어 둔다”다.
이 안경으로 메일을 요청하는 건 두 번째 이야기에서 해 본다.
정리하면
첫 번째 이야기에서 한 일을 정리하면 이렇다.
- 인증: 기기별 키 + 요청마다 HMAC 서명 + 타임스탬프 + Nonce + 실패 잠금. 실패 이유는 로그에만
- 가족: 사람 층을 두고 관리자, 어른, 아이 역할로. 최대 4명
- 권한: 실제 권한은 기기 scope AND 역할 상한. 요청과 승인은 다른 scope로 나눴다
- 관리: 사용자 등록과 키 발급은 NAS 안에서 CLI로만. 외부 API로는 열지 않는다
- 오류 응답: 무슨 일이 있어도 정해진 형식으로만, 원인은 서버 로그에만
이번 이야기에서도 삽질이 있었다. 한글 기기 이름 때문에 절대 통과할 수 없는 키가 만들어졌다. 그리고 설계를 한 번 크게 고쳤다. “나 혼자”에서 “우리 가족”으로. 클라이언트를 만들기 전에 알아챈 게 다행이다. 역시 보안 코드는 “되는지”보다 “막히는지”를 봐야 한다.
두 번째 이야기에서는 이 문지기를 통과한 요청이 처음으로 하는 일, 메일 발송을 붙인다. 한 번 나가면 되돌릴 수 없는 일이라 승인 대기열부터 만들어야 한다.
그럼 안녕!

꼬리말을 불러오는 중...