diff --git a/.claude/launch.json b/.claude/launch.json index 809006e..eaf3427 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["--prefix", "frontend", "run", "dev"], "port": 5173 + }, + { + "name": "backend-dev", + "runtimeExecutable": "backend\\run_dev.cmd", + "runtimeArgs": [], + "port": 8000 } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 51021fd..0e9f546 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,3 +107,5 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM | `OPENAI_API_KEY` | — | OpenAI API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | +| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) | +| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 | diff --git a/backend/.env.example b/backend/.env.example index 1a4a74a..ffdec00 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= # 蓄積知識をシステムプロンプトに注入する際の最大件数 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..3a34c40 --- /dev/null +++ b/backend/app/api/auth.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e766123..ad50edc 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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") diff --git a/backend/app/core/site_auth.py b/backend/app/core/site_auth.py new file mode 100644 index 0000000..1c30152 --- /dev/null +++ b/backend/app/core/site_auth.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 27a1b03..1bde96c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/run_dev.cmd b/backend/run_dev.cmd new file mode 100644 index 0000000..fb5fc2e --- /dev/null +++ b/backend/run_dev.cmd @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d0c7da7..7ad8974 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@