API Key 인증 완전 제거

사내 운영 전제로 X-API-Key 인증을 전면 제거.
- app/core/auth.py 삭제
- routes.py에서 Depends(require_api_key) 모두 제거
- config.Settings에서 api_keys / api_key_set 제거
- .env / .env.example에서 API_KEYS 제거
- docker-compose.yml에서 API_KEYS 환경변수 제거 (KOSIMCSE_MODEL 추가)
- UI(index.html)에서 DEFAULT_API_KEY 상수 + X-API-Key 헤더 모두 제거
- scripts/sample_curl.sh, sample_python.py에서 키 헤더 제거
- tests: test_detect_requires_api_key → test_detect_no_auth_required로 갱신
- README: 인증 컬럼 제거, curl 예시에서 헤더 제거
main
hbyang 2026-05-14 08:58:28 +09:00
parent f31ef142e8
commit 4913bf3ecc
10 changed files with 27 additions and 78 deletions

View File

@ -4,7 +4,6 @@ PORT=8000
LOG_LEVEL=info LOG_LEVEL=info
RELOAD=false RELOAD=false
API_KEYS=combooks-key-change-me,baikal-key-change-me
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
REFERENCE_CORPUS_DIR=./data/reference REFERENCE_CORPUS_DIR=./data/reference
TAXONOMY_DIR=./data/taxonomy TAXONOMY_DIR=./data/taxonomy

View File

@ -74,7 +74,6 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# 탐지 # 탐지
curl -X POST http://localhost:8000/v1/plagiarism/detect \ curl -X POST http://localhost:8000/v1/plagiarism/detect \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: combooks-key-change-me" \
-d '{ -d '{
"doc_id": "test-001", "doc_id": "test-001",
"text": "검사할 본문 텍스트…", "text": "검사할 본문 텍스트…",
@ -84,17 +83,15 @@ curl -X POST http://localhost:8000/v1/plagiarism/detect \
# 자서전 업로드 # 자서전 업로드
curl -X POST http://localhost:8000/v1/corpus \ curl -X POST http://localhost:8000/v1/corpus \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: combooks-key-change-me" \
-d '{"title": "김OO 자서전", "text": "본문…"}' -d '{"title": "김OO 자서전", "text": "본문…"}'
# .txt 파일 업로드 # .txt 파일 업로드
curl -X POST http://localhost:8000/v1/corpus/file \ curl -X POST http://localhost:8000/v1/corpus/file \
-H "X-API-Key: combooks-key-change-me" \
-F "title=김OO 자서전" \ -F "title=김OO 자서전" \
-F "file=@autobiography.txt" -F "file=@autobiography.txt"
# 코퍼스 목록 # 코퍼스 목록
curl -H "X-API-Key: combooks-key-change-me" http://localhost:8000/v1/corpus curl http://localhost:8000/v1/corpus
# 분류체계 조회 (10종 태그 + 39 케이스) # 분류체계 조회 (10종 태그 + 39 케이스)
curl http://localhost:8000/v1/taxonomy curl http://localhost:8000/v1/taxonomy
@ -102,21 +99,19 @@ curl http://localhost:8000/v1/taxonomy
## 엔드포인트 요약 ## 엔드포인트 요약
| Method | Path | 용도 | 인증 | | Method | Path | 용도 |
|---|---|---|---| |---|---|---|
| GET | `/` | 검토 콘솔 (HTML) | - | | GET | `/` | 검토 콘솔 (HTML) |
| GET | `/docs` | Swagger UI | - | | GET | `/docs` | Swagger UI |
| GET | `/v1/health` | 헬스체크 + 엔진 정보 | - | | GET | `/v1/health` | 헬스체크 + 엔진 정보 |
| GET | `/v1/taxonomy` | 분류체계 조회 | - | | GET | `/v1/taxonomy` | 분류체계 조회 |
| POST | `/v1/plagiarism/detect` | 단건 동기 탐지 | ✅ | | POST | `/v1/plagiarism/detect` | 단건 동기 탐지 |
| POST | `/v1/plagiarism/batch` | 배치 잡 등록 (≤500건) | ✅ | | POST | `/v1/plagiarism/batch` | 배치 잡 등록 (≤500건) |
| GET | `/v1/plagiarism/batch/{job_id}` | 배치 결과 조회 | ✅ | | GET | `/v1/plagiarism/batch/{job_id}` | 배치 결과 조회 |
| GET | `/v1/corpus` | 코퍼스 목록 | ✅ | | GET | `/v1/corpus` | 코퍼스 목록 |
| POST | `/v1/corpus` | JSON 업로드 | ✅ | | POST | `/v1/corpus` | JSON 업로드 |
| POST | `/v1/corpus/file` | .txt 파일 업로드 | ✅ | | POST | `/v1/corpus/file` | .txt 파일 업로드 |
| DELETE | `/v1/corpus/{doc_id}` | 삭제 | ✅ | | DELETE | `/v1/corpus/{doc_id}` | 삭제 |
인증: `X-API-Key` 헤더에 발급된 키 (기관별 분리 발급 가능, 콤마 구분).
## 응답 스키마 (탐지 결과) ## 응답 스키마 (탐지 결과)
@ -159,7 +154,6 @@ curl http://localhost:8000/v1/taxonomy
| 변수 | 기본 | 설명 | | 변수 | 기본 | 설명 |
|---|---|---| |---|---|---|
| `API_KEYS` | `combooks-key-…,baikal-key-…` | 콤마 구분 다중 키 |
| `SIMILARITY_THRESHOLD` | `0.85` | 침해 판정 임계값 (정밀도 우선) | | `SIMILARITY_THRESHOLD` | `0.85` | 침해 판정 임계값 (정밀도 우선) |
| `AUTOBIOGRAPHY_MODE` | `true` | 자서전 특화 전처리 (공통표현+NER) | | `AUTOBIOGRAPHY_MODE` | `true` | 자서전 특화 전처리 (공통표현+NER) |
| `USE_LSH_FILTER` | `true` | MinHash+LSH 1차 필터 | | `USE_LSH_FILTER` | `true` | MinHash+LSH 1차 필터 |

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Request, UploadFile, status from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Request, UploadFile, status
from app.api.schemas import ( from app.api.schemas import (
BatchCreatedResponse, BatchCreatedResponse,
@ -17,7 +17,6 @@ from app.api.schemas import (
HealthResponse, HealthResponse,
TaxonomyResponse, TaxonomyResponse,
) )
from app.core.auth import require_api_key
from app.core.config import get_settings from app.core.config import get_settings
from app.engine.corpus import add_document, delete_document, list_documents from app.engine.corpus import add_document, delete_document, list_documents
from app.engine.detector import PlagiarismDetector from app.engine.detector import PlagiarismDetector
@ -79,7 +78,6 @@ async def taxonomy(request: Request) -> TaxonomyResponse:
"/plagiarism/detect", "/plagiarism/detect",
response_model=DetectResponse, response_model=DetectResponse,
tags=["plagiarism"], tags=["plagiarism"],
dependencies=[Depends(require_api_key)],
) )
async def detect(req: DetectRequest, request: Request) -> DetectResponse: async def detect(req: DetectRequest, request: Request) -> DetectResponse:
return _detector(request).detect_request(req) return _detector(request).detect_request(req)
@ -90,7 +88,6 @@ async def detect(req: DetectRequest, request: Request) -> DetectResponse:
response_model=BatchCreatedResponse, response_model=BatchCreatedResponse,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
tags=["plagiarism"], tags=["plagiarism"],
dependencies=[Depends(require_api_key)],
) )
async def batch_create( async def batch_create(
req: BatchRequest, req: BatchRequest,
@ -113,7 +110,6 @@ async def batch_create(
"/plagiarism/batch/{job_id}", "/plagiarism/batch/{job_id}",
response_model=BatchStatusResponse, response_model=BatchStatusResponse,
tags=["plagiarism"], tags=["plagiarism"],
dependencies=[Depends(require_api_key)],
) )
async def batch_status(job_id: str, request: Request) -> BatchStatusResponse: async def batch_status(job_id: str, request: Request) -> BatchStatusResponse:
job = _job_store(request).get(job_id) job = _job_store(request).get(job_id)
@ -142,7 +138,6 @@ def _rebuild(request: Request) -> int:
"/corpus", "/corpus",
response_model=CorpusListResponse, response_model=CorpusListResponse,
tags=["corpus"], tags=["corpus"],
dependencies=[Depends(require_api_key)],
) )
async def corpus_list(request: Request) -> CorpusListResponse: async def corpus_list(request: Request) -> CorpusListResponse:
settings = get_settings() settings = get_settings()
@ -158,7 +153,6 @@ async def corpus_list(request: Request) -> CorpusListResponse:
response_model=CorpusUploadResponse, response_model=CorpusUploadResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
tags=["corpus"], tags=["corpus"],
dependencies=[Depends(require_api_key)],
) )
async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse: async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> CorpusUploadResponse:
"""JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드.""" """JSON으로 자서전 1건 업로드. 인덱스 자동 재빌드."""
@ -183,7 +177,6 @@ async def corpus_upload_json(req: CorpusUploadRequest, request: Request) -> Corp
response_model=CorpusUploadResponse, response_model=CorpusUploadResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
tags=["corpus"], tags=["corpus"],
dependencies=[Depends(require_api_key)],
) )
async def corpus_upload_file( async def corpus_upload_file(
request: Request, request: Request,
@ -218,7 +211,6 @@ async def corpus_upload_file(
"/corpus/{doc_id}", "/corpus/{doc_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
tags=["corpus"], tags=["corpus"],
dependencies=[Depends(require_api_key)],
) )
async def corpus_delete(doc_id: str, request: Request) -> None: async def corpus_delete(doc_id: str, request: Request) -> None:
settings = get_settings() settings = get_settings()

View File

@ -1,13 +0,0 @@
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
async def require_api_key(x_api_key: str | None = Header(default=None)) -> str:
settings = get_settings()
if not x_api_key or x_api_key not in settings.api_key_set:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing X-API-Key header",
)
return x_api_key

View File

@ -13,7 +13,6 @@ class Settings(BaseSettings):
log_level: str = "info" # debug / info / warning / error log_level: str = "info" # debug / info / warning / error
reload: bool = False # 개발용 자동 재시작 reload: bool = False # 개발용 자동 재시작
api_keys: str = "combooks-key-change-me,baikal-key-change-me"
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2" engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
reference_corpus_dir: str = "./data/reference" reference_corpus_dir: str = "./data/reference"
taxonomy_dir: str = "./data/taxonomy" taxonomy_dir: str = "./data/taxonomy"
@ -49,10 +48,6 @@ class Settings(BaseSettings):
autobiography_mode: bool = True autobiography_mode: bool = True
enable_entity_masking: bool = True enable_entity_masking: bool = True
@property
def api_key_set(self) -> set[str]:
return {k.strip() for k in self.api_keys.split(",") if k.strip()}
@property @property
def corpus_path(self) -> Path: def corpus_path(self) -> Path:
return Path(self.reference_corpus_dir).resolve() return Path(self.reference_corpus_dir).resolve()

View File

@ -358,9 +358,6 @@
</footer> </footer>
<script> <script>
// 검토 콘솔 내부 호출에 자동 첨부할 기본 키 (사내 운영용)
const DEFAULT_API_KEY = "combooks-key-change-me";
const SAMPLES = { const SAMPLES = {
copy: { copy: {
title: "어미만 변경한 표절 (홍길동전 기반)", title: "어미만 변경한 표절 (홍길동전 기반)",
@ -423,7 +420,6 @@ async function runDetect() {
const docId = document.getElementById("doc-id").value.trim() || `web-${Date.now()}`; const docId = document.getElementById("doc-id").value.trim() || `web-${Date.now()}`;
const title = document.getElementById("title").value.trim(); const title = document.getElementById("title").value.trim();
const author = document.getElementById("author").value.trim(); const author = document.getElementById("author").value.trim();
const apiKey = DEFAULT_API_KEY;
const thresholdVal = parseFloat(document.getElementById("threshold-slider").value); const thresholdVal = parseFloat(document.getElementById("threshold-slider").value);
const autobioMode = document.querySelector('input[name="autobio-mode"]:checked').value; const autobioMode = document.querySelector('input[name="autobio-mode"]:checked').value;
@ -451,7 +447,7 @@ async function runDetect() {
try { try {
const resp = await fetch("/v1/plagiarism/detect", { const resp = await fetch("/v1/plagiarism/detect", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
@ -614,11 +610,10 @@ document.querySelectorAll(".tab-btn").forEach((btn) => {
// ========== 코퍼스 관리 ========== // ========== 코퍼스 관리 ==========
async function loadCorpus() { async function loadCorpus() {
const apiKey = DEFAULT_API_KEY;
const tbody = document.getElementById("corpus-tbody"); const tbody = document.getElementById("corpus-tbody");
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>';
try { try {
const resp = await fetch("/v1/corpus", { headers: { "X-API-Key": apiKey } }); const resp = await fetch("/v1/corpus");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const data = await resp.json();
document.getElementById("corpus-count").textContent = data.total; document.getElementById("corpus-count").textContent = data.total;
@ -647,7 +642,6 @@ async function uploadCorpus() {
const title = document.getElementById("corpus-title").value.trim(); const title = document.getElementById("corpus-title").value.trim();
const text = document.getElementById("corpus-text").value.trim(); const text = document.getElementById("corpus-text").value.trim();
const file = document.getElementById("corpus-file").files[0]; const file = document.getElementById("corpus-file").files[0];
const apiKey = DEFAULT_API_KEY;
const statusEl = document.getElementById("corpus-status"); const statusEl = document.getElementById("corpus-status");
if (!title) { if (!title) {
@ -670,11 +664,11 @@ async function uploadCorpus() {
fd.append("title", title); fd.append("title", title);
if (docId) fd.append("doc_id", docId); if (docId) fd.append("doc_id", docId);
fd.append("file", file); fd.append("file", file);
resp = await fetch("/v1/corpus/file", { method: "POST", headers: { "X-API-Key": apiKey }, body: fd }); resp = await fetch("/v1/corpus/file", { method: "POST", body: fd });
} else { } else {
resp = await fetch("/v1/corpus", { resp = await fetch("/v1/corpus", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc_id: docId || null, title, text }), body: JSON.stringify({ doc_id: docId || null, title, text }),
}); });
} }
@ -700,11 +694,9 @@ async function uploadCorpus() {
async function deleteDoc(docId) { async function deleteDoc(docId) {
if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return; if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return;
const apiKey = DEFAULT_API_KEY;
try { try {
const resp = await fetch(`/v1/corpus/${encodeURIComponent(docId)}`, { const resp = await fetch(`/v1/corpus/${encodeURIComponent(docId)}`, {
method: "DELETE", method: "DELETE",
headers: { "X-API-Key": apiKey },
}); });
if (!resp.ok && resp.status !== 204) { if (!resp.ok && resp.status !== 204) {
throw new Error(`HTTP ${resp.status}`); throw new Error(`HTTP ${resp.status}`);

View File

@ -12,6 +12,7 @@ services:
REFERENCE_CORPUS_DIR: /app/data/reference REFERENCE_CORPUS_DIR: /app/data/reference
TAXONOMY_DIR: /app/data/taxonomy TAXONOMY_DIR: /app/data/taxonomy
AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt AUTOBIOGRAPHY_PATTERNS_PATH: /app/data/autobiography/common_patterns.txt
KOSIMCSE_MODEL: ${KOSIMCSE_MODEL:-BM-K/KoSimCSE-roberta-multitask}
volumes: volumes:
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped

View File

@ -3,7 +3,6 @@
set -euo pipefail set -euo pipefail
API_HOST="${API_HOST:-http://localhost:8000}" API_HOST="${API_HOST:-http://localhost:8000}"
API_KEY="${API_KEY:-combooks-key-change-me}"
echo "--- 1) Health ---" echo "--- 1) Health ---"
curl -sS "${API_HOST}/v1/health" | python3 -m json.tool curl -sS "${API_HOST}/v1/health" | python3 -m json.tool
@ -12,7 +11,6 @@ echo
echo "--- 2) 단건 탐지: 어린왕자 유사 텍스트 ---" echo "--- 2) 단건 탐지: 어린왕자 유사 텍스트 ---"
curl -sS -X POST "${API_HOST}/v1/plagiarism/detect" \ curl -sS -X POST "${API_HOST}/v1/plagiarism/detect" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-d '{ -d '{
"doc_id": "test-001", "doc_id": "test-001",
"text": "어린왕자는 작은 별에서 온 소년이다. 그는 별을 떠나 여러 행성을 여행하며 다양한 어른들을 만난다. 마침내 지구에 도착해 여우를 만나고 길들임의 의미를 배운다.", "text": "어린왕자는 작은 별에서 온 소년이다. 그는 별을 떠나 여러 행성을 여행하며 다양한 어른들을 만난다. 마침내 지구에 도착해 여우를 만나고 길들임의 의미를 배운다.",
@ -23,7 +21,6 @@ echo
echo "--- 3) 배치 등록 ---" echo "--- 3) 배치 등록 ---"
JOB_RESPONSE=$(curl -sS -X POST "${API_HOST}/v1/plagiarism/batch" \ JOB_RESPONSE=$(curl -sS -X POST "${API_HOST}/v1/plagiarism/batch" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-API-Key: ${API_KEY}" \
-d '{ -d '{
"items": [ "items": [
{"doc_id": "b-001", "text": "앤 셜리는 상상력이 풍부한 고아 소녀로 초록 지붕 집에 입양된다."}, {"doc_id": "b-001", "text": "앤 셜리는 상상력이 풍부한 고아 소녀로 초록 지붕 집에 입양된다."},
@ -36,5 +33,4 @@ JOB_ID=$(echo "${JOB_RESPONSE}" | python3 -c 'import json,sys; print(json.load(s
echo echo
echo "--- 4) 배치 결과 조회 ---" echo "--- 4) 배치 결과 조회 ---"
sleep 1 sleep 1
curl -sS "${API_HOST}/v1/plagiarism/batch/${JOB_ID}" \ curl -sS "${API_HOST}/v1/plagiarism/batch/${JOB_ID}" | python3 -m json.tool
-H "X-API-Key: ${API_KEY}" | python3 -m json.tool

View File

@ -10,14 +10,13 @@ import time
import urllib.request import urllib.request
API_HOST = os.environ.get("API_HOST", "http://localhost:8000") API_HOST = os.environ.get("API_HOST", "http://localhost:8000")
API_KEY = os.environ.get("API_KEY", "combooks-key-change-me")
def _post(path: str, body: dict) -> dict: def _post(path: str, body: dict) -> dict:
req = urllib.request.Request( req = urllib.request.Request(
f"{API_HOST}{path}", f"{API_HOST}{path}",
data=json.dumps(body).encode("utf-8"), data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "X-API-Key": API_KEY}, headers={"Content-Type": "application/json"},
method="POST", method="POST",
) )
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
@ -25,7 +24,7 @@ def _post(path: str, body: dict) -> dict:
def _get(path: str) -> dict: def _get(path: str) -> dict:
req = urllib.request.Request(f"{API_HOST}{path}", headers={"X-API-Key": API_KEY}) req = urllib.request.Request(f"{API_HOST}{path}")
with urllib.request.urlopen(req) as resp: with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()) return json.loads(resp.read())

View File

@ -5,8 +5,6 @@ from fastapi.testclient import TestClient
from app.main import app from app.main import app
API_KEY = "combooks-key-change-me"
def test_health(): def test_health():
with TestClient(app) as client: with TestClient(app) as client:
@ -17,13 +15,14 @@ def test_health():
assert body["corpus_size"] >= 0 assert body["corpus_size"] >= 0
def test_detect_requires_api_key(): def test_detect_no_auth_required():
"""인증 제거 - 키 없이도 200 응답."""
with TestClient(app) as client: with TestClient(app) as client:
resp = client.post( resp = client.post(
"/v1/plagiarism/detect", "/v1/plagiarism/detect",
json={"doc_id": "x", "text": "테스트 본문"}, json={"doc_id": "x", "text": "테스트 본문"},
) )
assert resp.status_code == 401 assert resp.status_code == 200
def test_detect_returns_schema(): def test_detect_returns_schema():
@ -34,7 +33,6 @@ def test_detect_returns_schema():
"doc_id": "t-1", "doc_id": "t-1",
"text": "어린왕자는 작은 별에서 온 소년이다. 그는 여우를 만나 길들임을 배운다.", "text": "어린왕자는 작은 별에서 온 소년이다. 그는 여우를 만나 길들임을 배운다.",
}, },
headers={"X-API-Key": API_KEY},
) )
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
@ -53,12 +51,8 @@ def test_batch_flow():
{"doc_id": "b-1", "text": "앤 셜리는 초록 지붕 집에 입양된 소녀다."}, {"doc_id": "b-1", "text": "앤 셜리는 초록 지붕 집에 입양된 소녀다."},
] ]
}, },
headers={"X-API-Key": API_KEY},
) )
assert resp.status_code == 202 assert resp.status_code == 202
job_id = resp.json()["job_id"] job_id = resp.json()["job_id"]
status = client.get( status = client.get(f"/v1/plagiarism/batch/{job_id}")
f"/v1/plagiarism/batch/{job_id}",
headers={"X-API-Key": API_KEY},
)
assert status.status_code == 200 assert status.status_code == 200