Add Authentik OIDC login
Same Authlib-based pattern as v1: SessionMiddleware + core/auth.py (login/callback/logout + require_login dependency), gating "/", /asr/* and /tts/* behind Authentik (application slug "asr-v2", provider pk 14). The websocket route can't use a FastAPI dependency (no Request in its scope) so it's split into its own router and checks websocket.session manually before accept(). v2 has no public domain yet, so the redirect_uri points at the internal 172.30.1.41:18101 address over plain http — hence https_only=False on the session cookie. Client id/secret and the session secret live in /srv/asr/env/asr.env under ASR_V2_-prefixed names (that env file is shared with v1, which already owns the unprefixed OIDC_* names). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
66
app/core/auth.py
Normal file
66
app/core/auth.py
Normal file
@@ -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
|
||||
21
app/main.py
21
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():
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<header class="page-header">
|
||||
<h1>ASR · TTS 콘솔</h1>
|
||||
<p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p>
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1>ASR · TTS 콘솔</h1>
|
||||
<p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p>
|
||||
</div>
|
||||
<a href="/auth/logout" class="btn-secondary logout-link" title="로그아웃">로그아웃</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tab-group tab-group--top">
|
||||
|
||||
@@ -5,3 +5,5 @@ httpx==0.27.2
|
||||
websockets>=12.0
|
||||
jinja2==3.1.4
|
||||
numpy
|
||||
authlib==1.3.2
|
||||
itsdangerous==2.2.0
|
||||
|
||||
Reference in New Issue
Block a user