Add site-wide login gate protecting the whole app

Chat/memory/logs/models APIs were reachable without any auth even
though the app is deployed publicly behind Caddy HTTPS; only the
settings screen had a password. Add an optional SITE_PASSWORD-backed
session cookie gate in front of everything (settings included, on
top of its existing SETTINGS_PASSWORD), with a matching login screen
on the frontend. Stays fully backward compatible: with SITE_PASSWORD
unset the app behaves exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:04:01 +09:00
co-authored by Claude Sonnet 5
parent 002282a8b7
commit 1ad8b130c7
12 changed files with 319 additions and 38 deletions
+11
View File
@@ -21,6 +21,17 @@ DEBUG=false
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
SETTINGS_PASSWORD=
# サイト全体(チャット含む全機能)を保護する簡易パスワード。SETTINGS_PASSWORDとは別物。
# 未設定だとこれまで通りサイト全体が無認証で使える。外部公開する場合は必ず設定すること。
SITE_PASSWORD=
# セッションCookieの署名鍵。未設定でも動くが、その場合バックエンド再起動のたびに全員ログインし直しになる。
# 公開環境では `openssl rand -hex 32` などで生成した値を設定しておくこと。
SESSION_SECRET_KEY=
# ローカルのhttp開発環境(HTTPS化していない)でログインが効かない場合のみ false にする。公開環境では変更しないこと。
SESSION_COOKIE_SECURE=true
# ログインセッションの有効日数
SESSION_MAX_AGE_DAYS=30
# Tavily Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
TAVILY_API_KEY=
# 蓄積知識をシステムプロンプトに注入する際の最大件数
+50
View File
@@ -0,0 +1,50 @@
import secrets
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel
from app.core.config import SESSION_COOKIE_SECURE, SESSION_MAX_AGE_DAYS, SITE_PASSWORD
from app.core.site_auth import COOKIE_NAME, create_session_token, verify_session_token
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
password: str
class AuthStatus(BaseModel):
required: bool
authenticated: bool
@router.get("/status", response_model=AuthStatus)
def status(request: Request):
if not SITE_PASSWORD:
return AuthStatus(required=False, authenticated=True)
token = request.cookies.get(COOKIE_NAME)
return AuthStatus(required=True, authenticated=bool(token and verify_session_token(token)))
@router.post("/login", response_model=AuthStatus)
def login(req: LoginRequest, response: Response):
if not SITE_PASSWORD:
raise HTTPException(status_code=403, detail="SITE_PASSWORD is not configured on the server")
if not secrets.compare_digest(req.password, SITE_PASSWORD):
raise HTTPException(status_code=401, detail="Invalid password")
response.set_cookie(
COOKIE_NAME,
create_session_token(),
max_age=SESSION_MAX_AGE_DAYS * 86400,
httponly=True,
secure=SESSION_COOKIE_SECURE,
samesite="lax",
path="/",
)
return AuthStatus(required=True, authenticated=True)
@router.post("/logout", response_model=AuthStatus)
def logout(response: Response):
response.delete_cookie(COOKIE_NAME, path="/")
return AuthStatus(required=bool(SITE_PASSWORD), authenticated=False)
+9
View File
@@ -1,5 +1,6 @@
from dotenv import load_dotenv
import os
import secrets
load_dotenv()
@@ -12,6 +13,14 @@ DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD")
# サイト全体(設定画面以外の全API)を保護する簡易パスワード。未設定の場合はこれまで通り無認証で動く。
SITE_PASSWORD: str | None = os.getenv("SITE_PASSWORD")
# セッションCookieの署名に使う秘密鍵。未設定なら起動毎に使い捨てキーを生成する(バックエンド再起動でログインし直しになる)。
SESSION_SECRET_KEY: str = os.getenv("SESSION_SECRET_KEY") or secrets.token_hex(32)
# ローカルのhttp開発環境ではSecure Cookieが送信されないため、その場合のみfalseにする。公開環境では必ずtrueのままにすること。
SESSION_COOKIE_SECURE: bool = os.getenv("SESSION_COOKIE_SECURE", "true").lower() in ("1", "true", "yes")
SESSION_MAX_AGE_DAYS: int = int(os.getenv("SESSION_MAX_AGE_DAYS", "30"))
# メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
TAGGING_MODEL: str | None = os.getenv("TAGGING_MODEL")
+40
View File
@@ -0,0 +1,40 @@
import hashlib
import hmac
import time
from fastapi import HTTPException, Request
from app.core.config import SESSION_MAX_AGE_DAYS, SESSION_SECRET_KEY, SITE_PASSWORD
COOKIE_NAME = "playchat_session"
def _sign(payload: str) -> str:
return hmac.new(SESSION_SECRET_KEY.encode(), payload.encode(), hashlib.sha256).hexdigest()
def create_session_token() -> str:
issued_at = str(int(time.time()))
return f"{issued_at}.{_sign(issued_at)}"
def verify_session_token(token: str) -> bool:
issued_at, _, signature = token.partition(".")
if not issued_at or not signature:
return False
if not hmac.compare_digest(_sign(issued_at), signature):
return False
age_seconds = time.time() - int(issued_at)
return 0 <= age_seconds <= SESSION_MAX_AGE_DAYS * 86400
def require_site_session(request: Request) -> None:
"""サイト全体(設定画面以外の全API)を保護するセッションCookie認証。
SITE_PASSWORD が未設定の場合は既存のローカル開発体験を壊さないよう常に通す。
"""
if not SITE_PASSWORD:
return
token = request.cookies.get(COOKIE_NAME)
if not token or not verify_session_token(token):
raise HTTPException(status_code=401, detail="Login required")
+10 -7
View File
@@ -1,14 +1,16 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import auth as auth_api
from app.api import chat, logs, memory, system
from app.api import models as models_api
from app.api import settings as settings_api
from app.core import settings_store
from app.core.config import CORS_ORIGINS
from app.core.site_auth import require_site_session
from app.core.discord.discord_bot import start_discord_bot
from app.core.discord.scheduler import start_scheduler
from app.core.memory_kb.word_memory import seed_from_history
@@ -88,12 +90,13 @@ app.add_middleware(
)
app.include_router(chat.router)
app.include_router(memory.router)
app.include_router(logs.router)
app.include_router(system.router)
app.include_router(settings_api.router)
app.include_router(models_api.router)
app.include_router(auth_api.router)
app.include_router(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(memory.router, dependencies=[Depends(require_site_session)])
app.include_router(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(system.router, dependencies=[Depends(require_site_session)])
app.include_router(settings_api.router, dependencies=[Depends(require_site_session)])
app.include_router(models_api.router, dependencies=[Depends(require_site_session)])
@app.get("/health")
+6
View File
@@ -0,0 +1,6 @@
@echo off
rem SSLKEYLOGFILE がこのマシンのTLS監視プロキシの名前付きパイプを指していると
rem litellm(OpenSSLを直接ロード)が OPENSSL_Uplink エラーで起動時にクラッシュするため、
rem このコマンドの実行中だけ unset する(CLAUDE.md の「ローカル開発環境メモ」参照)。
set SSLKEYLOGFILE=
uv run --directory backend uvicorn app.main:app --reload --port 8000