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:
du5t
2026-06-18 13:44:20 +09:00
parent cabbdac39b
commit 6b9a9cce8f
6 changed files with 99 additions and 7 deletions

View File

@@ -24,6 +24,7 @@ from asr.config import (
) )
router = APIRouter() router = APIRouter()
ws_router = APIRouter()
BACKENDS = { BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL, "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() return resp.json()
@router.websocket("/ws/realtime") @ws_router.websocket("/ws/realtime")
async def realtime_ws(websocket: WebSocket) -> None: async def realtime_ws(websocket: WebSocket) -> None:
if not websocket.session.get("user"):
await websocket.close(code=4401)
return
await websocket.accept() await websocket.accept()
audio_buf = bytearray() audio_buf = bytearray()

66
app/core/auth.py Normal file
View 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

View File

@@ -3,16 +3,27 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
from fastapi import FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from asr.config import ensure_runtime_dirs from asr.config import ensure_runtime_dirs
from asr.router import router as asr_router 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 from tts.router import router as tts_router
app = FastAPI(title="ASR/TTS Gateway", docs_url="/docs", redoc_url="/redoc") 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -23,8 +34,10 @@ app.add_middleware(
UI_DIR = Path("/app/ui") UI_DIR = Path("/app/ui")
app.include_router(asr_router, prefix="/asr", tags=["asr"]) app.include_router(auth_router, prefix="/auth", tags=["auth"])
app.include_router(tts_router, prefix="/tts", tags=["tts"]) 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") @app.on_event("startup")
@@ -40,7 +53,7 @@ def health() -> Dict[str, Any]:
return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"} 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: def root() -> Any:
index = UI_DIR / "index.html" index = UI_DIR / "index.html"
if index.exists(): if index.exists():

View File

@@ -8,6 +8,8 @@ body {
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px; } .wrap { max-width: 1100px; margin: 0 auto; padding: 24px; }
.page-header { margin-bottom: 8px; } .page-header { margin-bottom: 8px; }
.page-header h1 { margin: 0 0 4px; } .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; } .muted { color: #aab3cf; margin: 0; }
.small { font-size: 0.87em; } .small { font-size: 0.87em; }
.mono { font-family: monospace; } .mono { font-family: monospace; }

View File

@@ -9,8 +9,13 @@
<body> <body>
<main class="wrap"> <main class="wrap">
<header class="page-header"> <header class="page-header">
<div class="header-row">
<div>
<h1>ASR · TTS 콘솔</h1> <h1>ASR · TTS 콘솔</h1>
<p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p> <p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p>
</div>
<a href="/auth/logout" class="btn-secondary logout-link" title="로그아웃">로그아웃</a>
</div>
</header> </header>
<div class="tab-group tab-group--top"> <div class="tab-group tab-group--top">

View File

@@ -5,3 +5,5 @@ httpx==0.27.2
websockets>=12.0 websockets>=12.0
jinja2==3.1.4 jinja2==3.1.4
numpy numpy
authlib==1.3.2
itsdangerous==2.2.0