제 목 :
우리집 비서 만들기 프로젝트 #5 - 서버편: 서명과 문지기

4편에서 가족과 기기, 그리고 권한을 정했다. 이제 드디어 Secretary의 문지기를 세운다.
요청마다 HMAC 서명을 만들고 확인하는 코드를 쓰고, 그 문을 통과한 요청이 처음 만나는 API를 열고, 마지막으로 앱을 하나로 조립한다.
Step 1. 서명 만들기 (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 2. 문지기 (guard.py)
이번 편의 핵심이다. /v1로 들어오는 모든 요청은 이 문지기를 통과해야 한다.
C#이나 Express 서버의 경우엔 middleware, Java Spring의 경우엔 Filter, Interceptor 같은 역할이다.
"""모든 /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 allows, 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 can(self, scope: str) -> bool: """이 호출자가 scope 하나를 쓸 수 있는지. 와일드카드(mail:*, modules:*)도 따진다.""" return allows(self.scopes, scope)
def forbid(self, request: Request, scope: str) -> HTTPException: """권한 없음을 감사 로그에 남기고, 돌려줄 403 예외를 만든다. 부르는 쪽에서 raise 한다.""" request.app.state.audit.write( "auth.forbidden", user=self.user.id, device=self.device.access_key, ip=self.ip, scope=scope, path=request.url.path, ) return FORBIDDEN
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 not caller.can(scope): raise caller.forbid(request, scope) return caller
return dependency검사 순서를 그림으로 그리면 이렇다.
몇 가지 짚고 넘어가자.
- 실패 이유는 응답에 안 쓴다. 서명이 틀렸든, 시간이 지났든, 키가 폐기됐든 응답은 똑같이
401 인증에 실패했습니다.하나다. 진짜 이유는 감사 로그에만 남긴다. 시큐어 코딩 글에서 “오류 페이지는 아무것도 말하지 않게”라고 했던 그 얘기다. 어디서 막혔는지 알려 주면 공격자에게 힌트가 된다. - 서명 비교는
hmac.compare_digest로 한다. 그냥==로 비교하면 앞 글자부터 비교하다가 다른 글자가 나오는 순간 멈추기 때문에, 응답 시간 차이로 서명을 한 글자씩 맞혀 가는 타이밍 공격이 가능하고 한다. 나도 이번에 알게된 놀라운 공격법이다. 대단한 인내심인 것 같다가도 해킹 기법이라는게 정말 대단하구나 싶다. - Nonce는 120초 동안만 기억한다. 타임스탬프 허용 범위(±60초)의 두 배다. 그보다 오래된 요청은 어차피 타임스탬프 검사에서 걸린다. 그래서 Nonce 테이블이 끝없이 커지지 않는다.
- 기기 주인도 확인한다. 기기 키가 살아 있어도, 그 사람이 비활성화됐으면 막힌다. 그리고 여기서 실제 권한(기기 scope AND 역할 상한)을 계산해서
Caller에 담아 둔다. API에서는caller.can("mail:send")처럼 묻기만 하면 와일드카드까지 따져서 답해 준다. - 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 3. 첫 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(/v1/commands)와 승인 API는 8편에서 만든다.
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 4. 앱 조립 (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 .approvals import ApprovalQueuefrom .catalog import ModuleCatalogfrom .events import EventStorefrom .routers import approvals, commands, events, 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, module_transport=None) -> FastAPI: """FastAPI 앱을 만든다. 테스트에서는 settings와 가짜 모듈(module_transport)을 넣어 따로 만든다.
운영 서버는 맨 아래 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.events = EventStore(app.state.db) app.state.catalog = ModuleCatalog(settings.module_endpoints, settings.module_timeout_seconds, transport=module_transport) app.state.audit.write("server.start", modules=sorted(settings.module_endpoints)) yield await app.state.catalog.close() 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(commands.router) app.include_router(events.router) app.include_router(events.internal) 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 함수. 3편에서 본 @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 연결, 사용자·기기 저장소, 감사 로그, 승인 대기열, 이벤트 보관함, 모듈 카탈로그(모듈 목록과 모듈을 부르는 HTTP 클라이언트)를 한 번씩 만들어서
app.state에 넣어 두고, 요청마다 그걸 꺼내 쓴다. 꺼질 때는 HTTP 연결과 DB 연결을 닫는다. Spring의@PostConstruct와@PreDestroy를 한 함수에 합친 셈이다.
정리하면
- 인증: 기기별 키 + 요청마다 HMAC 서명 + 타임스탬프 + Nonce + 실패 잠금. 실패 이유는 로그에만
- 첫 API:
GET /v1/me. 서명이 맞으면 내가 누구인지와 실제 권한을 돌려준다 - 오류 응답: 무슨 일이 있어도 정해진 형식으로만, 원인은 서버 로그에만
이제 서명이 맞는 요청만 들어오는 허브가 돌아간다. 그런데 아직 등록된 가족이 없다. 다음 6편에서는 가족을 등록하는 CLI를 만들고, 문이 정말 막히는지 테스트하고, 예제 클라이언트로 직접 불러 본다.
그럼 안녕!

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