diff --git a/app/asr/router.py b/app/asr/router.py index d353ea4..bf8b689 100644 --- a/app/asr/router.py +++ b/app/asr/router.py @@ -24,6 +24,7 @@ from asr.config import ( ) router = APIRouter() +ws_router = APIRouter() BACKENDS = { "faster-whisper": FASTER_WHISPER_URL, @@ -267,8 +268,11 @@ async def _send_chunk(wav_bytes: bytes, *, model: str, language: str, beam_size: return resp.json() -@router.websocket("/ws/realtime") +@ws_router.websocket("/ws/realtime") async def realtime_ws(websocket: WebSocket) -> None: + if not websocket.session.get("user"): + await websocket.close(code=4401) + return await websocket.accept() audio_buf = bytearray() diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..900231c --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any, Dict + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from core.config import env_str + +OIDC_ISSUER = env_str("ASR_V2_OIDC_ISSUER", "") +OIDC_CLIENT_ID = env_str("ASR_V2_OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = env_str("ASR_V2_OIDC_CLIENT_SECRET", "") +OIDC_REDIRECT_URI = env_str("ASR_V2_OIDC_REDIRECT_URI", "") +SESSION_SECRET_KEY = env_str("ASR_V2_SESSION_SECRET_KEY", "") + +oauth = OAuth() +oauth.register( + name="authentik", + client_id=OIDC_CLIENT_ID, + client_secret=OIDC_CLIENT_SECRET, + server_metadata_url=f"{OIDC_ISSUER}.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, +) + +router = APIRouter() + + +@router.get("/login") +async def login(request: Request, next: str = "/"): + request.session["next"] = next if next.startswith("/") else "/" + return await oauth.authentik.authorize_redirect(request, OIDC_REDIRECT_URI) + + +@router.get("/callback") +async def callback(request: Request): + try: + token = await oauth.authentik.authorize_access_token(request) + except Exception as exc: + raise HTTPException(status_code=401, detail=f"로그인 실패: {exc}") from exc + + userinfo = token.get("userinfo") or {} + request.session["user"] = { + "sub": userinfo.get("sub"), + "username": userinfo.get("preferred_username") or userinfo.get("nickname"), + "email": userinfo.get("email"), + "name": userinfo.get("name"), + } + next_path = request.session.pop("next", "/") or "/" + return RedirectResponse(url=next_path) + + +@router.get("/logout") +async def logout(request: Request): + request.session.pop("user", None) + return RedirectResponse(url="/") + + +def require_login(request: Request) -> Dict[str, Any]: + user = request.session.get("user") + if not user: + raise HTTPException( + status_code=303, + headers={"Location": f"/auth/login?next={request.url.path}"}, + ) + return user diff --git a/app/main.py b/app/main.py index 6fa6505..e3a6cd7 100644 --- a/app/main.py +++ b/app/main.py @@ -3,16 +3,27 @@ from __future__ import annotations from pathlib import Path from typing import Any, Dict -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware from asr.config import ensure_runtime_dirs from asr.router import router as asr_router +from asr.router import ws_router as asr_ws_router +from core.auth import SESSION_SECRET_KEY, require_login +from core.auth import router as auth_router from tts.router import router as tts_router app = FastAPI(title="ASR/TTS Gateway", docs_url="/docs", redoc_url="/redoc") +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET_KEY, + same_site="lax", + https_only=False, # 아직 전용 도메인/TLS가 없어 내부 IP:포트(http)로 접근하기 때문 + max_age=14 * 24 * 3600, +) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -23,8 +34,10 @@ app.add_middleware( UI_DIR = Path("/app/ui") -app.include_router(asr_router, prefix="/asr", tags=["asr"]) -app.include_router(tts_router, prefix="/tts", tags=["tts"]) +app.include_router(auth_router, prefix="/auth", tags=["auth"]) +app.include_router(asr_router, prefix="/asr", tags=["asr"], dependencies=[Depends(require_login)]) +app.include_router(asr_ws_router, prefix="/asr", tags=["asr"]) # 인증은 핸들러 내부에서 세션으로 직접 체크 +app.include_router(tts_router, prefix="/tts", tags=["tts"], dependencies=[Depends(require_login)]) @app.on_event("startup") @@ -40,7 +53,7 @@ def health() -> Dict[str, Any]: return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"} -@app.get("/", response_class=HTMLResponse) +@app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_login)]) def root() -> Any: index = UI_DIR / "index.html" if index.exists(): diff --git a/app/ui/assets/css/styles.css b/app/ui/assets/css/styles.css index e5e2d2e..10013b8 100644 --- a/app/ui/assets/css/styles.css +++ b/app/ui/assets/css/styles.css @@ -8,6 +8,8 @@ body { .wrap { max-width: 1100px; margin: 0 auto; padding: 24px; } .page-header { margin-bottom: 8px; } .page-header h1 { margin: 0 0 4px; } +.header-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.logout-link { text-decoration: none; flex-shrink: 0; display: inline-block; } .muted { color: #aab3cf; margin: 0; } .small { font-size: 0.87em; } .mono { font-family: monospace; } diff --git a/app/ui/index.html b/app/ui/index.html index ec5dddd..9893f0b 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -9,8 +9,13 @@
diff --git a/envs/gateway.txt b/envs/gateway.txt index d63113d..aa15656 100644 --- a/envs/gateway.txt +++ b/envs/gateway.txt @@ -5,3 +5,5 @@ httpx==0.27.2 websockets>=12.0 jinja2==3.1.4 numpy +authlib==1.3.2 +itsdangerous==2.2.0