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
+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "frontend", "run", "dev"], "runtimeArgs": ["--prefix", "frontend", "run", "dev"],
"port": 5173 "port": 5173
},
{
"name": "backend-dev",
"runtimeExecutable": "backend\\run_dev.cmd",
"runtimeArgs": [],
"port": 8000
} }
] ]
} }
+2
View File
@@ -107,3 +107,5 @@ SQLite (playchat.db) + LiteLLM → 任意の LLM
| `OPENAI_API_KEY` | — | OpenAI API キー | | `OPENAI_API_KEY` | — | OpenAI API キー |
| `ANTHROPIC_API_KEY` | — | Anthropic API キー | | `ANTHROPIC_API_KEY` | — | Anthropic API キー |
| `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント | | `OLLAMA_API_BASE` | `http://localhost:11434` | Ollama エンドポイント |
| `SITE_PASSWORD` | — | サイト全体を保護する共有パスワード(未設定なら無認証) |
| `SESSION_SECRET_KEY` | 起動毎に自動生成 | ログインセッション Cookie の署名鍵 |
+11
View File
@@ -21,6 +21,17 @@ DEBUG=false
# HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。 # HTTPS化していない環境ではBasic認証の資格情報が平文で流れるため、公開環境ではHTTPS化とセットで使うこと。
SETTINGS_PASSWORD= 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 Web検索(未設定だとweb_searchツールは何も検索せず空振りする)
TAVILY_API_KEY= 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 from dotenv import load_dotenv
import os import os
import secrets
load_dotenv() load_dotenv()
@@ -12,6 +13,14 @@ DEBUG: bool = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
# GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。 # GUI設定画面(/api/settings)を保護する簡易パスワード。未設定の場合、設定画面は常に403で拒否する。
SETTINGS_PASSWORD: str | None = os.getenv("SETTINGS_PASSWORD") 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を使用) # メッセージへの自動タグ付けに使うモデル(未設定ならDEFAULT_MODELを使用)
TAGGING_MODEL: str | None = os.getenv("TAGGING_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 import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware 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 chat, logs, memory, system
from app.api import models as models_api from app.api import models as models_api
from app.api import settings as settings_api from app.api import settings as settings_api
from app.core import settings_store from app.core import settings_store
from app.core.config import CORS_ORIGINS 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.discord_bot import start_discord_bot
from app.core.discord.scheduler import start_scheduler from app.core.discord.scheduler import start_scheduler
from app.core.memory_kb.word_memory import seed_from_history 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(auth_api.router)
app.include_router(memory.router) app.include_router(chat.router, dependencies=[Depends(require_site_session)])
app.include_router(logs.router) app.include_router(memory.router, dependencies=[Depends(require_site_session)])
app.include_router(system.router) app.include_router(logs.router, dependencies=[Depends(require_site_session)])
app.include_router(settings_api.router) app.include_router(system.router, dependencies=[Depends(require_site_session)])
app.include_router(models_api.router) 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") @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
+24 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import SessionList from './components/SessionList.vue' import SessionList from './components/SessionList.vue'
import ChatWindow from './components/ChatWindow.vue' import ChatWindow from './components/ChatWindow.vue'
import MessageInput from './components/MessageInput.vue' import MessageInput from './components/MessageInput.vue'
@@ -10,6 +10,10 @@ import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue' import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue' import Clock from './components/Clock.vue'
import WordPool from './components/WordPool.vue' import WordPool from './components/WordPool.vue'
import LoginGate from './components/LoginGate.vue'
import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings' type SidePanel = 'none' | 'logs' | 'settings'
const activePanel = ref<SidePanel>('none') const activePanel = ref<SidePanel>('none')
@@ -17,9 +21,24 @@ const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) { function togglePanel(panel: SidePanel) {
activePanel.value = activePanel.value === panel ? 'none' : panel activePanel.value = activePanel.value === panel ? 'none' : panel
} }
function handleUnauthorized() {
authStore.markUnauthenticated()
}
onMounted(() => {
authStore.checkStatus()
window.addEventListener('playchat:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('playchat:unauthorized', handleUnauthorized)
})
</script> </script>
<template> <template>
<template v-if="authStore.loading" />
<LoginGate v-else-if="authStore.required && !authStore.authenticated" />
<template v-else>
<div class="app"> <div class="app">
<div class="sidebar"> <div class="sidebar">
<SessionList /> <SessionList />
@@ -37,6 +56,9 @@ function togglePanel(panel: SidePanel) {
<button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')"> <button class="log-toggle" :class="{ active: activePanel === 'logs' }" @click="togglePanel('logs')">
ログ ログ
</button> </button>
<button v-if="authStore.required" class="log-toggle" @click="authStore.logout()">
ログアウト
</button>
</header> </header>
<div class="body"> <div class="body">
<div class="chat-pane"> <div class="chat-pane">
@@ -53,6 +75,7 @@ function togglePanel(panel: SidePanel) {
</div> </div>
</div> </div>
<ToastList /> <ToastList />
</template>
</template> </template>
<style> <style>
+24
View File
@@ -34,6 +34,7 @@ async function parseJson<T>(res: Response, label: string): Promise<T> {
throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`) throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`)
} }
if (!res.ok) { if (!res.ok) {
if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized'))
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
throw new Error(`${label}: ${body.detail ?? res.statusText}`) throw new Error(`${label}: ${body.detail ?? res.statusText}`)
} }
@@ -81,6 +82,29 @@ export async function deleteLog(logId: number): Promise<void> {
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' }) await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
} }
export interface AuthStatus {
required: boolean
authenticated: boolean
}
export async function fetchAuthStatus(): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/status`)
return parseJson<AuthStatus>(res, '認証状態取得')
}
export async function login(password: string): Promise<AuthStatus> {
const res = await fetch(`${BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
return parseJson<AuthStatus>(res, 'ログイン')
}
export async function logout(): Promise<void> {
await fetch(`${BASE}/api/auth/logout`, { method: 'POST' })
}
export interface SettingEntry { export interface SettingEntry {
key: string key: string
is_set: boolean is_set: boolean
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
const store = useAuthStore()
const passwordInput = ref('')
async function submit() {
const ok = await store.login(passwordInput.value)
if (ok) passwordInput.value = ''
}
</script>
<template>
<div class="login-gate">
<form class="card" @submit.prevent="submit">
<span class="title">PlayChat</span>
<p class="hint">サイトを利用するにはパスワードが必要です。</p>
<input
v-model="passwordInput"
type="password"
placeholder="パスワード"
autocomplete="current-password"
autofocus
/>
<button type="submit" :disabled="store.loading || !passwordInput">
{{ store.loading ? '確認中...' : 'ログイン' }}
</button>
<p v-if="store.error" class="error">{{ store.error }}</p>
</form>
</div>
</template>
<style scoped>
.login-gate {
height: 100vh; display: flex; align-items: center; justify-content: center;
background: #111;
}
.card {
display: flex; flex-direction: column; gap: 10px;
width: 280px; padding: 24px; background: #1a1a1a;
border: 1px solid #2a2a2a; border-radius: 10px;
}
.title { font-size: 18px; font-weight: 600; color: #fff; }
.hint { font-size: 12px; color: #888; }
input {
background: #2a2a2a; color: #eee; border: 1px solid #444;
border-radius: 6px; padding: 8px 10px; font-size: 13px; width: 100%;
}
button {
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
border-radius: 6px; padding: 8px 10px; font-size: 13px; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
.error { font-size: 12px; color: #f87171; }
</style>
+51
View File
@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchAuthStatus, login as loginApi, logout as logoutApi } from '../api/client'
export const useAuthStore = defineStore('auth', () => {
const required = ref(false)
const authenticated = ref(false)
const loading = ref(true)
const error = ref<string | null>(null)
async function checkStatus(): Promise<void> {
loading.value = true
try {
const status = await fetchAuthStatus()
required.value = status.required
authenticated.value = status.authenticated
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
} finally {
loading.value = false
}
}
async function login(password: string): Promise<boolean> {
loading.value = true
error.value = null
try {
const status = await loginApi(password)
required.value = status.required
authenticated.value = status.authenticated
return true
} catch (e) {
authenticated.value = false
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
return false
} finally {
loading.value = false
}
}
async function logout(): Promise<void> {
await logoutApi().catch(() => {})
authenticated.value = false
}
function markUnauthenticated(): void {
authenticated.value = false
}
return { required, authenticated, loading, error, checkStatus, login, logout, markUnauthenticated }
})