From 81ee4b1ea40ec2bad6d817d6c21a1cc265e8b856 Mon Sep 17 00:00:00 2001 From: chenjie Date: Thu, 23 Apr 2026 20:45:04 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(auth):=20merge=20feat/local=5Faccount?= =?UTF-8?q?=20=E2=80=94=20single-admin=20account=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add login/setup-admin/force-change-password pages and auth-guarded routing - Rewrite AdminUsers page with self-service password reset - Integrate require_user guard into session routes Made-with: Cursor --- flocks/auth/__init__.py | 8 + flocks/auth/context.py | 44 ++ flocks/auth/service.py | 515 ++++++++++++++++++ flocks/cli/commands/__init__.py | 2 + flocks/cli/commands/admin.py | 124 +++++ flocks/cli/main.py | 2 + flocks/server/app.py | 44 ++ flocks/server/auth.py | 291 ++++++++++ flocks/server/routes/admin_users.py | 73 +++ flocks/server/routes/auth.py | 163 ++++++ flocks/server/routes/session.py | 102 +++- flocks/session/session.py | 136 ++++- flocks/utils/id.py | 2 + .../server/routes/test_admin_users_routes.py | 59 ++ tests/server/routes/test_session_routes.py | 80 +++ tests/server/test_auth_compat.py | 179 ++++++ tests/session/test_session.py | 45 ++ tests/session/test_session_advanced.py | 34 +- tests/utils/test_id_compatibility.py | 3 + tui/sdk/client.ts | 37 +- tui/sdk/v2/client.ts | 37 +- webui/src/App.tsx | 7 +- webui/src/api/auth.ts | 57 ++ webui/src/api/client.test.ts | 21 + webui/src/api/client.ts | 51 +- webui/src/api/session.ts | 10 + webui/src/components/common/PasswordInput.tsx | 38 ++ webui/src/components/common/SessionChat.tsx | 4 +- webui/src/components/layout/Layout.tsx | 49 +- webui/src/contexts/AuthContext.tsx | 116 ++++ webui/src/hooks/useSSE.test.tsx | 73 +++ webui/src/hooks/useSSE.ts | 7 +- webui/src/pages/AdminUsers/index.tsx | 151 +++++ webui/src/pages/Config/index.tsx | 128 +---- webui/src/pages/ForceChangePassword/index.tsx | 107 ++++ webui/src/pages/Login/index.tsx | 76 +++ webui/src/pages/Session/index.tsx | 58 +- webui/src/pages/SetupAdmin/index.tsx | 77 +++ webui/src/routes/index.tsx | 64 ++- webui/src/types/index.ts | 7 + 40 files changed, 2879 insertions(+), 202 deletions(-) create mode 100644 flocks/auth/__init__.py create mode 100644 flocks/auth/context.py create mode 100644 flocks/auth/service.py create mode 100644 flocks/cli/commands/admin.py create mode 100644 flocks/server/auth.py create mode 100644 flocks/server/routes/admin_users.py create mode 100644 flocks/server/routes/auth.py create mode 100644 tests/server/routes/test_admin_users_routes.py create mode 100644 tests/server/test_auth_compat.py create mode 100644 webui/src/api/auth.ts create mode 100644 webui/src/api/client.test.ts create mode 100644 webui/src/components/common/PasswordInput.tsx create mode 100644 webui/src/contexts/AuthContext.tsx create mode 100644 webui/src/hooks/useSSE.test.tsx create mode 100644 webui/src/pages/AdminUsers/index.tsx create mode 100644 webui/src/pages/ForceChangePassword/index.tsx create mode 100644 webui/src/pages/Login/index.tsx create mode 100644 webui/src/pages/SetupAdmin/index.tsx diff --git a/flocks/auth/__init__.py b/flocks/auth/__init__.py new file mode 100644 index 000000000..79d0d1a46 --- /dev/null +++ b/flocks/auth/__init__.py @@ -0,0 +1,8 @@ +""" +Local account authentication package. +""" + +from flocks.auth.context import AuthUser +from flocks.auth.service import AuthService + +__all__ = ["AuthUser", "AuthService"] diff --git a/flocks/auth/context.py b/flocks/auth/context.py new file mode 100644 index 000000000..0e69f82c6 --- /dev/null +++ b/flocks/auth/context.py @@ -0,0 +1,44 @@ +""" +Authentication context helpers for request-scoped user identity. +""" + +from __future__ import annotations + +import contextvars +from typing import Optional + +from pydantic import BaseModel, Field + + +class AuthUser(BaseModel): + """Current authenticated local user.""" + + id: str + username: str + role: str = Field(..., description="admin or member") + status: str = Field("active", description="active or disabled") + must_reset_password: bool = False + + +_current_auth_user: contextvars.ContextVar[Optional[AuthUser]] = contextvars.ContextVar( + "current_auth_user", + default=None, +) + + +def set_current_auth_user(user: Optional[AuthUser]) -> contextvars.Token: + """Set current request user in context.""" + + return _current_auth_user.set(user) + + +def reset_current_auth_user(token: contextvars.Token) -> None: + """Reset request user context.""" + + _current_auth_user.reset(token) + + +def get_current_auth_user() -> Optional[AuthUser]: + """Get current request user from context.""" + + return _current_auth_user.get() diff --git a/flocks/auth/service.py b/flocks/auth/service.py new file mode 100644 index 000000000..2efc538f6 --- /dev/null +++ b/flocks/auth/service.py @@ -0,0 +1,515 @@ +""" +Local account/authentication service. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +from datetime import UTC, datetime, timedelta +from typing import Dict, List, Optional, Tuple + +import aiosqlite +from pydantic import BaseModel, Field + +from flocks.auth.context import AuthUser +from flocks.storage.storage import Storage +from flocks.utils.id import Identifier +from flocks.utils.log import Log + +log = Log.create(service="auth.service") + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _iso_now() -> str: + return _utc_now().isoformat() + + +def _parse_iso(ts: str) -> datetime: + parsed = datetime.fromisoformat(ts) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +class LocalUser(BaseModel): + id: str + username: str + role: str + status: str + must_reset_password: bool + created_at: str + updated_at: str + last_login_at: Optional[str] = None + + def to_auth_user(self) -> AuthUser: + return AuthUser( + id=self.id, + username=self.username, + role=self.role, + status=self.status, + must_reset_password=self.must_reset_password, + ) + + +class AuthService: + """Single-admin account and session service.""" + + _initialized: bool = False + _initialized_db_path: Optional[str] = None + _session_ttl_days: int = 7 + _temp_password_ttl_hours: int = 24 + + @classmethod + async def init(cls) -> None: + await Storage.init() + db_path = Storage.get_db_path() + if cls._initialized and cls._initialized_db_path == str(db_path) and db_path.exists(): + return + async with aiosqlite.connect(db_path) as db: + await db.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + status TEXT NOT NULL DEFAULT 'active', + must_reset_password INTEGER NOT NULL DEFAULT 0, + temp_password_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT + ); + + CREATE TABLE IF NOT EXISTS user_sessions ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); + + """ + ) + await cls._drop_legacy_tables(db) + await db.commit() + + cls._initialized = True + cls._initialized_db_path = str(db_path) + log.info("auth.initialized") + + @classmethod + async def _drop_legacy_tables(cls, db: aiosqlite.Connection) -> None: + removed_tables = ("_".join(("cloud", "binding")),) + for table_name in removed_tables: + async with db.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = ? + """, + (table_name,), + ) as cursor: + row = await cursor.fetchone() + if not row: + continue + await db.execute(f"DROP TABLE IF EXISTS {table_name}") + log.info("auth.legacy_table.dropped", {"table": table_name}) + + @classmethod + def _hash_password(cls, password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1) + return "scrypt$" + base64.b64encode(salt).decode("ascii") + "$" + base64.b64encode(digest).decode("ascii") + + @classmethod + def _verify_password(cls, password: str, password_hash: str) -> bool: + try: + scheme, salt_b64, digest_b64 = password_hash.split("$", 2) + if scheme != "scrypt": + return False + salt = base64.b64decode(salt_b64.encode("ascii")) + expected = base64.b64decode(digest_b64.encode("ascii")) + actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1) + return hmac.compare_digest(actual, expected) + except Exception: + return False + + @classmethod + async def has_users(cls) -> bool: + await cls.init() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + async with db.execute("SELECT COUNT(1) FROM users") as cursor: + row = await cursor.fetchone() + return bool(row and row[0] > 0) + + @classmethod + async def get_bootstrap_status(cls) -> Dict[str, bool]: + has_users = await cls.has_users() + return {"bootstrapped": has_users} + + @classmethod + async def bootstrap_admin(cls, username: str, password: str) -> LocalUser: + await cls.init() + if await cls.has_users(): + raise ValueError("账号体系已初始化") + user = await cls._create_user_internal( + username=username, + password=password, + role="admin", + must_reset_password=False, + ) + await cls.migrate_legacy_sessions_to_admin(user.id) + return user + + @classmethod + async def _create_user_internal( + cls, + username: str, + password: str, + role: str = "member", + must_reset_password: bool = False, + temp_expires_at: Optional[str] = None, + ) -> LocalUser: + await cls.init() + if role not in {"admin", "member"}: + raise ValueError("无效角色") + normalized_username = username.strip() + if not normalized_username: + raise ValueError("用户名不能为空") + if len(password) < 8: + raise ValueError("密码长度至少 8 位") + + user_id = Identifier.ascending("user") + now = _iso_now() + password_hash = cls._hash_password(password) + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + await db.execute( + """ + INSERT INTO users ( + id, username, password_hash, role, status, must_reset_password, + temp_password_expires_at, created_at, updated_at + ) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?) + """, + ( + user_id, + normalized_username, + password_hash, + role, + 1 if must_reset_password else 0, + temp_expires_at, + now, + now, + ), + ) + await db.commit() + return await cls.get_user_by_id(user_id) # type: ignore[return-value] + + @classmethod + async def get_user_by_id(cls, user_id: str) -> Optional[LocalUser]: + await cls.init() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + async with db.execute( + """ + SELECT id, username, role, status, must_reset_password, + created_at, updated_at, last_login_at + FROM users WHERE id = ? + """, + (user_id,), + ) as cursor: + row = await cursor.fetchone() + if not row: + return None + return LocalUser( + id=row[0], + username=row[1], + role=row[2], + status=row[3], + must_reset_password=bool(row[4]), + created_at=row[5], + updated_at=row[6], + last_login_at=row[7], + ) + + @classmethod + async def get_user_by_username(cls, username: str) -> Optional[Tuple[LocalUser, str, Optional[str]]]: + await cls.init() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + async with db.execute( + """ + SELECT id, username, role, status, must_reset_password, created_at, updated_at, last_login_at, + password_hash, temp_password_expires_at + FROM users WHERE username = ? + """, + (username.strip(),), + ) as cursor: + row = await cursor.fetchone() + if not row: + return None + user = LocalUser( + id=row[0], + username=row[1], + role=row[2], + status=row[3], + must_reset_password=bool(row[4]), + created_at=row[5], + updated_at=row[6], + last_login_at=row[7], + ) + return user, row[8], row[9] + + @classmethod + async def list_users(cls) -> List[LocalUser]: + await cls.init() + db_path = Storage.get_db_path() + users: List[LocalUser] = [] + async with aiosqlite.connect(db_path) as db: + async with db.execute( + """ + SELECT id, username, role, status, must_reset_password, created_at, updated_at, last_login_at + FROM users + ORDER BY created_at ASC + """ + ) as cursor: + rows = await cursor.fetchall() + for row in rows: + users.append( + LocalUser( + id=row[0], + username=row[1], + role=row[2], + status=row[3], + must_reset_password=bool(row[4]), + created_at=row[5], + updated_at=row[6], + last_login_at=row[7], + ) + ) + return users + + @classmethod + async def _create_session(cls, user_id: str) -> str: + await cls.init() + session_id = secrets.token_urlsafe(32) + now = _iso_now() + expires_at = (_utc_now() + timedelta(days=cls._session_ttl_days)).isoformat() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + await db.execute( + """ + INSERT INTO user_sessions(session_id, user_id, expires_at, created_at, updated_at) + VALUES(?, ?, ?, ?, ?) + """, + (session_id, user_id, expires_at, now, now), + ) + await db.commit() + return session_id + + @classmethod + async def get_user_by_session_id(cls, session_id: str) -> Optional[LocalUser]: + await cls.init() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + async with db.execute( + """ + SELECT u.id, u.username, u.role, u.status, u.must_reset_password, u.created_at, u.updated_at, u.last_login_at, + s.expires_at + FROM user_sessions s + JOIN users u ON s.user_id = u.id + WHERE s.session_id = ? + """, + (session_id,), + ) as cursor: + row = await cursor.fetchone() + if not row: + return None + expires_at = _parse_iso(row[8]) + if _utc_now() >= expires_at: + await cls.revoke_session(session_id) + return None + user = LocalUser( + id=row[0], + username=row[1], + role=row[2], + status=row[3], + must_reset_password=bool(row[4]), + created_at=row[5], + updated_at=row[6], + last_login_at=row[7], + ) + if user.status != "active": + return None + return user + + @classmethod + async def revoke_session(cls, session_id: str) -> None: + await cls.init() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + await db.execute("DELETE FROM user_sessions WHERE session_id = ?", (session_id,)) + await db.commit() + + @classmethod + async def login( + cls, + username: str, + password: str, + ) -> Tuple[LocalUser, str]: + user_with_hash = await cls.get_user_by_username(username) + if not user_with_hash: + raise ValueError("用户名或密码错误") + + user, password_hash, temp_expires_at = user_with_hash + if user.status != "active": + raise ValueError("账号已被禁用") + + valid = cls._verify_password(password, password_hash) + if not valid: + raise ValueError("用户名或密码错误") + + if temp_expires_at: + expiry = _parse_iso(temp_expires_at) + if _utc_now() > expiry: + raise ValueError("一次性密码已过期,请联系管理员重置") + + session_id = await cls._create_session(user.id) + now = _iso_now() + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + await db.execute("UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?", (now, now, user.id)) + await db.commit() + + updated_user = await cls.get_user_by_id(user.id) + if not updated_user: + raise ValueError("登录失败") + + return updated_user, session_id + + @classmethod + async def change_password( + cls, + user: AuthUser, + *, + current_password: str, + new_password: str, + ) -> None: + existing = await cls.get_user_by_username(user.username) + if not existing: + raise ValueError("用户不存在") + _, password_hash, _ = existing + if not cls._verify_password(current_password, password_hash): + raise ValueError("当前密码错误") + await cls.set_password( + target_user_id=user.id, + new_password=new_password, + must_reset_password=False, + temp_password_expires_at=None, + ) + + @classmethod + async def set_password( + cls, + *, + target_user_id: str, + new_password: str, + must_reset_password: bool, + temp_password_expires_at: Optional[str] = None, + ) -> None: + if len(new_password) < 8: + raise ValueError("密码长度至少 8 位") + await cls.init() + now = _iso_now() + pwd_hash = cls._hash_password(new_password) + db_path = Storage.get_db_path() + async with aiosqlite.connect(db_path) as db: + cursor = await db.execute( + """ + UPDATE users + SET password_hash = ?, must_reset_password = ?, temp_password_expires_at = ?, updated_at = ? + WHERE id = ? + """, + ( + pwd_hash, + 1 if must_reset_password else 0, + temp_password_expires_at, + now, + target_user_id, + ), + ) + await db.commit() + if cursor.rowcount == 0: + raise ValueError("用户不存在") + # Security hardening: revoke all active sessions after password change/reset. + await db.execute("DELETE FROM user_sessions WHERE user_id = ?", (target_user_id,)) + await db.commit() + + @classmethod + async def generate_admin_temp_password( + cls, + *, + username: str = "admin", + ) -> str: + user_info = await cls.get_user_by_username(username) + if not user_info: + raise ValueError("管理员账号不存在") + user, _, _ = user_info + if user.role != "admin": + raise ValueError("目标账号不是管理员") + temp_password = secrets.token_urlsafe(12) + expires = (_utc_now() + timedelta(hours=cls._temp_password_ttl_hours)).isoformat() + await cls.set_password( + target_user_id=user.id, + new_password=temp_password, + must_reset_password=True, + temp_password_expires_at=expires, + ) + return temp_password + + @classmethod + async def migrate_legacy_sessions_to_admin(cls, admin_user_id: str) -> None: + """Set owner on legacy sessions without owner_user_id.""" + marker_key = "auth:migration:legacy_session_owner_to_admin" + marker = await Storage.get(marker_key, dict) + if marker and marker.get("done"): + return + try: + from flocks.session.session import Session + + admin_user = await cls.get_user_by_id(admin_user_id) + admin_username = admin_user.username if admin_user else None + sessions = await Session.list_all() + migrated = 0 + for session in sessions: + if session.owner_user_id: + continue + await Session.update( + project_id=session.project_id, + session_id=session.id, + owner_user_id=admin_user_id, + owner_username=admin_username, + visibility="private", + ) + migrated += 1 + await Storage.set( + marker_key, + {"done": True, "migrated": migrated, "updated_at": _iso_now()}, + "json", + ) + except Exception as exc: + log.warn("auth.migrate_legacy_sessions.failed", {"error": str(exc)}) + raise diff --git a/flocks/cli/commands/__init__.py b/flocks/cli/commands/__init__.py index 9d04ba000..552458bfd 100644 --- a/flocks/cli/commands/__init__.py +++ b/flocks/cli/commands/__init__.py @@ -11,6 +11,7 @@ from flocks.cli.commands.skill import skill_app from flocks.cli.commands.stats import stats_app from flocks.cli.commands.task import task_app +from flocks.cli.commands.admin import admin_app __all__ = [ "session_app", @@ -20,4 +21,5 @@ "stats_app", "task_app", "skill_app", + "admin_app", ] diff --git a/flocks/cli/commands/admin.py b/flocks/cli/commands/admin.py new file mode 100644 index 000000000..c852d2fbe --- /dev/null +++ b/flocks/cli/commands/admin.py @@ -0,0 +1,124 @@ +""" +Admin account maintenance commands. +""" + +from __future__ import annotations + +import asyncio +import secrets + +import typer +from rich.console import Console +from rich.table import Table + +from flocks.auth.service import AuthService +from flocks.config.config import Config +from flocks.security import get_secret_manager +from flocks.server.auth import API_TOKEN_SECRET_ID + +admin_app = typer.Typer(help="管理员账号与安全维护命令") +console = Console() + + +@admin_app.command("list-users") +def list_users(): + """ + 列出本机已创建的账号,便于管理员找回账号名。 + """ + + async def _run(): + await AuthService.init() + return await AuthService.list_users() + + try: + users = asyncio.run(_run()) + except Exception as exc: + console.print(f"[red]获取账号列表失败: {exc}[/red]") + raise typer.Exit(1) from exc + + if not users: + console.print("[yellow]当前未创建任何账号[/yellow]") + return + + table = Table(title="账号列表") + table.add_column("用户名", style="bold") + table.add_column("角色") + table.add_column("状态") + table.add_column("最近登录") + + for user in users: + table.add_row( + user.username, + user.role, + user.status, + user.last_login_at or "-", + ) + + console.print(table) + + +@admin_app.command("generate-api-token") +def generate_api_token( + nbytes: int = typer.Option(32, "--bytes", "-b", min=16, max=128, help="随机字节数(建议 32)"), +): + """ + 生成并保存用于非浏览器调用的 API Token。 + """ + token = secrets.token_urlsafe(nbytes) + get_secret_manager().set(API_TOKEN_SECRET_ID, token) + + secret_file = Config.get_secret_file() + console.print("[yellow]已生成并保存 API Token(请妥善保存)[/yellow]") + console.print(f"[bold]{token}[/bold]") + console.print("") + console.print(f"[dim]保存位置: {secret_file}[/dim]") + console.print(f"[dim]secret_id: {API_TOKEN_SECRET_ID}[/dim]") + + +@admin_app.command("set-api-token") +def set_api_token( + token: str = typer.Option( + ..., + "--token", + "-t", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="要写入的 API Token", + ), +): + """ + 将指定 API Token 写入本机 .secret.json(用于远程 CLI 客户端或服务端配置)。 + """ + normalized = token.strip() + if len(normalized) < 16: + console.print("[red]API Token 长度过短,至少 16 个字符[/red]") + raise typer.Exit(1) + + get_secret_manager().set(API_TOKEN_SECRET_ID, normalized) + secret_file = Config.get_secret_file() + console.print("[yellow]API Token 已写入本机 secret 存储[/yellow]") + console.print(f"[dim]保存位置: {secret_file}[/dim]") + console.print(f"[dim]secret_id: {API_TOKEN_SECRET_ID}[/dim]") + + +@admin_app.command("generate-one-time-password") +def generate_one_time_password( + username: str = typer.Option("admin", "--username", "-u", help="管理员用户名"), +): + """ + 在服务器上生成管理员一次性密码(首次登录需强制改密)。 + """ + + async def _run() -> str: + await AuthService.init() + return await AuthService.generate_admin_temp_password(username=username) + + try: + temp_password = asyncio.run(_run()) + except Exception as exc: + console.print(f"[red]生成一次性密码失败: {exc}[/red]") + raise typer.Exit(1) from exc + + console.print("[yellow]管理员一次性密码已生成(24小时有效,首次登录需改密)[/yellow]") + console.print(f"[bold]{temp_password}[/bold]") diff --git a/flocks/cli/main.py b/flocks/cli/main.py index d9be59ff4..9fdb9cf0d 100644 --- a/flocks/cli/main.py +++ b/flocks/cli/main.py @@ -17,6 +17,7 @@ from flocks import __version__ from flocks.cli.commands import ( + admin_app, export_app, import_app, mcp_app, @@ -57,6 +58,7 @@ app.add_typer(stats_app, name="stats") app.add_typer(task_app, name="task") app.add_typer(skill_app, name="skills") +app.add_typer(admin_app, name="admin") app.command(name="update")(update_command) diff --git a/flocks/server/app.py b/flocks/server/app.py index 70e54b629..a1f504fad 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -19,6 +19,8 @@ from flocks.config.config import Config from flocks.storage.storage import Storage from flocks.utils.langfuse import initialize as init_observability, shutdown as shutdown_observability +from flocks.auth.service import AuthService +from flocks.server.auth import apply_auth_for_request, clear_auth_context # Load .env file at startup try: @@ -84,6 +86,20 @@ async def lifespan(app: FastAPI): # Initialize storage await Storage.init() log.info("storage.initialized") + + # Initialize local auth/account tables + await AuthService.init() + log.info("auth.initialized") + + # Best-effort migration: old sessions default to admin ownership. + try: + if await AuthService.has_users(): + users = await AuthService.list_users() + admin = next((u for u in users if u.role == "admin"), None) + if admin: + await AuthService.migrate_legacy_sessions_to_admin(admin.id) + except Exception as e: + log.warning("auth.legacy_sessions.migration_failed", {"error": str(e)}) # Setup question handler for real user interaction from flocks.tool.question_handler import setup_api_question_handler @@ -479,6 +495,28 @@ async def log_requests(request: Request, call_next): return response +@app.middleware("http") +async def auth_guard_middleware(request: Request, call_next): + """Guard requests with local account auth, except public endpoints.""" + try: + _blocked, token, _user = await apply_auth_for_request(request) + except StarletteHTTPException as exc: + return JSONResponse( + status_code=exc.status_code, + content={"error": "AuthError", "message": exc.detail}, + ) + except Exception as exc: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"error": "AuthError", "message": str(exc)}, + ) + + try: + return await call_next(request) + finally: + clear_auth_context(token) + + # Error Handlers @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): @@ -596,6 +634,8 @@ async def general_exception_handler(request: Request, exc: Exception): from flocks.server.routes.update import router as update_router # Log viewing from flocks.server.routes.logs import router as logs_router +from flocks.server.routes.auth import router as auth_router +from flocks.server.routes.admin_users import router as admin_users_router # Original routes with /api/ prefix app.include_router(health_router, prefix="/api", tags=["Health"]) app.include_router(session_router, prefix="/api/session", tags=["Session"]) @@ -646,6 +686,8 @@ async def general_exception_handler(request: Request, exc: Exception): app.include_router(update_router, prefix="/api/update", tags=["Update"]) # Log viewing routes app.include_router(logs_router, prefix="/api/logs", tags=["Logs"]) +app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) +app.include_router(admin_users_router, prefix="/api/admin", tags=["Admin"]) # ============================================================ # TUI Compatible Routes (without /api/ prefix) @@ -706,6 +748,8 @@ async def general_exception_handler(request: Request, exc: Exception): # TUI control routes (/tui/*) app.include_router(tui_router, prefix="/tui", tags=["TUI"]) +app.include_router(auth_router, prefix="/auth", tags=["Auth"]) +app.include_router(admin_users_router, prefix="/admin", tags=["Admin"]) @app.get("/", tags=["Root"]) diff --git a/flocks/server/auth.py b/flocks/server/auth.py new file mode 100644 index 000000000..171fa5754 --- /dev/null +++ b/flocks/server/auth.py @@ -0,0 +1,291 @@ +""" +FastAPI auth dependencies and cookie helpers. +""" + +from __future__ import annotations + +import hmac +from typing import Optional + +from fastapi import HTTPException, Request, Response, status + +from flocks.auth.context import AuthUser, reset_current_auth_user, set_current_auth_user +from flocks.auth.service import AuthService +from flocks.security import get_secret_manager + +SESSION_COOKIE_NAME = "flocks_session" +API_TOKEN_SECRET_ID = "server_api_token" + +PROTECTED_PREFIXES = ( + "/api", + "/session", + "/provider", + "/config", + "/project", + "/file", + "/mcp", + "/agent", + "/app/agent", + "/pty", + "/lsp", + "/path", + "/vcs", + "/find", + "/permission", + "/question", + "/tui", + "/global", + "/channel", + "/auth", + "/admin", + "/event", + "/logs", + "/update", + "/workspace", +) + + +def should_use_secure_cookie(request: Request) -> bool: + import os + + forced = os.getenv("FLOCKS_COOKIE_SECURE", "").strip().lower() + if forced in {"1", "true", "yes", "on"}: + return True + forwarded_proto = request.headers.get("x-forwarded-proto", "") + if forwarded_proto: + proto = forwarded_proto.split(",")[0].strip().lower() + if proto == "https": + return True + return request.url.scheme == "https" + + +def set_session_cookie(response: Response, session_id: str, *, secure: bool) -> None: + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_id, + httponly=True, + secure=secure, + samesite="lax", + max_age=7 * 24 * 3600, + path="/", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie(key=SESSION_COOKIE_NAME, path="/") + + +def get_request_ip(request: Request) -> Optional[str]: + if request.client: + return request.client.host + return None + + +def get_request_user_agent(request: Request) -> Optional[str]: + return request.headers.get("user-agent") + + +def get_optional_user(request: Request) -> Optional[AuthUser]: + user = getattr(request.state, "auth_user", None) + return user + + +def require_user(request: Request) -> AuthUser: + user = get_optional_user(request) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") + return user + + +def require_admin(request: Request) -> AuthUser: + user = require_user(request) + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可执行该操作") + return user + + +def auth_middleware_exempt(path: str) -> bool: + public_prefixes = { + "/health", + "/api/health", + "/docs", + "/redoc", + "/openapi.json", + "/api/auth/login", + "/api/auth/bootstrap-status", + "/api/auth/bootstrap-admin", + "/auth/login", + "/auth/bootstrap-status", + "/auth/bootstrap-admin", + } + return any(path == prefix or path.startswith(prefix + "/") for prefix in public_prefixes) + + +def password_reset_exempt(path: str) -> bool: + allowed_paths = { + "/api/auth/me", + "/api/auth/change-password", + "/api/auth/logout", + "/auth/me", + "/auth/change-password", + "/auth/logout", + } + return any(path == allowed or path.startswith(allowed + "/") for allowed in allowed_paths) + + +def is_protected_backend_path(path: str) -> bool: + return any(path == prefix or path.startswith(prefix + "/") for prefix in PROTECTED_PREFIXES) + + +def _is_browser_like_request(request: Request) -> bool: + """ + Identify browser-originated traffic (must keep strict login checks). + + We rely on standard browser headers, then fall back to User-Agent. + """ + headers = request.headers + if headers.get("origin"): + return True + if headers.get("sec-fetch-site") or headers.get("sec-fetch-mode") or headers.get("sec-fetch-dest"): + return True + user_agent = (headers.get("user-agent") or "").lower() + return "mozilla/" in user_agent + + +def _is_loopback_direct_request(request: Request) -> bool: + """ + Trust only local direct requests (no proxy forwarding headers). + """ + if request.headers.get("x-forwarded-for"): + return False + client_host = request.client.host if request.client else None + return client_host in {"127.0.0.1", "::1", "localhost", "testclient"} + + +def _read_api_token_from_request(request: Request) -> Optional[str]: + """ + Read API token from Authorization Bearer or x-flocks-api-token header. + """ + auth_header = request.headers.get("authorization") or request.headers.get("Authorization") + if auth_header: + scheme, _, value = auth_header.partition(" ") + if scheme.lower() == "bearer" and value.strip(): + return value.strip() + + alt = request.headers.get("x-flocks-api-token") + if alt and alt.strip(): + return alt.strip() + return None + + +def _get_expected_api_token() -> Optional[str]: + try: + token = get_secret_manager().get(API_TOKEN_SECRET_ID) + if token: + return token.strip() or None + return None + except Exception: + return None + + +def _is_valid_api_token(token: Optional[str]) -> bool: + expected = _get_expected_api_token() + if not expected or not token: + return False + return hmac.compare_digest(token, expected) + + +def _build_api_token_user() -> AuthUser: + """Synthetic service identity for API token clients.""" + return AuthUser( + id="api-token-service", + username="api-token-service", + role="admin", + status="active", + must_reset_password=False, + ) + + +def _build_local_service_user() -> AuthUser: + """Synthetic local service identity for loopback non-browser clients.""" + return AuthUser( + id="local-service", + username="local-service", + role="admin", + status="active", + must_reset_password=False, + ) + + +async def apply_auth_for_request(request: Request): + """ + Resolve user from cookie and bind context var. + Returns (response_if_blocked, token, user). + """ + if not is_protected_backend_path(request.url.path): + token = set_current_auth_user(None) + return None, token, None + + if auth_middleware_exempt(request.url.path): + token = set_current_auth_user(None) + return None, token, None + + # Non-browser clients: local loopback can run without token; remote requires API token. + if not _is_browser_like_request(request): + provided = _read_api_token_from_request(request) + if provided: + if not _is_valid_api_token(provided): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="非浏览器请求鉴权失败,请在 Authorization 中携带有效 Bearer API Token", + ) + token_user = _build_api_token_user() + request.state.auth_user = token_user + token = set_current_auth_user(token_user) + return None, token, token_user + + if _is_loopback_direct_request(request): + local_user = _build_local_service_user() + request.state.auth_user = local_user + token = set_current_auth_user(local_user) + return None, token, local_user + + expected = _get_expected_api_token() + if not expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"远程非浏览器请求需要 API Token,请先在 .secret.json 中配置 {API_TOKEN_SECRET_ID}", + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="远程非浏览器请求鉴权失败,请在 Authorization 中携带 Bearer API Token", + ) + + bootstrapped = await AuthService.has_users() + if not bootstrapped: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="系统尚未初始化管理员账号,请先完成初始化", + ) + + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if not session_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") + + user = await AuthService.get_user_by_session_id(session_id) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录") + + auth_user = user.to_auth_user() + request.state.auth_user = auth_user + token = set_current_auth_user(auth_user) + if auth_user.must_reset_password and not password_reset_exempt(request.url.path): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前账号必须先修改密码后才能继续使用", + ) + return None, token, auth_user + + +def clear_auth_context(token) -> None: + reset_current_auth_user(token) diff --git a/flocks/server/routes/admin_users.py b/flocks/server/routes/admin_users.py new file mode 100644 index 000000000..e41aa6a3c --- /dev/null +++ b/flocks/server/routes/admin_users.py @@ -0,0 +1,73 @@ +""" +Admin-only user management routes. +""" + +from __future__ import annotations + +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Request, status +from pydantic import BaseModel, Field + +from flocks.auth.service import AuthService +from flocks.server.auth import require_admin + +router = APIRouter() + + +class UserResponse(BaseModel): + id: str + username: str + role: str + status: str + must_reset_password: bool + created_at: str + updated_at: str + last_login_at: Optional[str] = None + + +class ResetPasswordRequest(BaseModel): + new_password: Optional[str] = Field(None, min_length=8, max_length=128) + force_reset: bool = True + + +@router.get("/users", response_model=List[UserResponse], summary="管理员获取用户列表") +async def list_users(request: Request) -> List[UserResponse]: + _admin = require_admin(request) + users = await AuthService.list_users() + return [UserResponse(**u.model_dump()) for u in users] + + +@router.post("/users/{user_id}/reset-password", summary="管理员重置密码") +async def reset_user_password(user_id: str, payload: ResetPasswordRequest, request: Request) -> dict: + require_admin(request) + target_user = await AuthService.get_user_by_id(user_id) + if not target_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + new_password = payload.new_password + if not new_password: + import secrets + + new_password = secrets.token_urlsafe(10) + + expires_at = None + if payload.force_reset: + from datetime import UTC, datetime, timedelta + + expires_at = (datetime.now(UTC) + timedelta(hours=24)).isoformat() + + try: + await AuthService.set_password( + target_user_id=user_id, + new_password=new_password, + must_reset_password=payload.force_reset, + temp_password_expires_at=expires_at, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return { + "success": True, + "temporary_password": new_password if payload.force_reset else None, + "must_reset_password": payload.force_reset, + } diff --git a/flocks/server/routes/auth.py b/flocks/server/routes/auth.py new file mode 100644 index 000000000..b8e345bd3 --- /dev/null +++ b/flocks/server/routes/auth.py @@ -0,0 +1,163 @@ +""" +Local account authentication routes. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, Response, status +from pydantic import BaseModel, Field + +from flocks.auth.service import AuthService +from flocks.server.auth import ( + clear_session_cookie, + require_user, + set_session_cookie, + should_use_secure_cookie, +) + +router = APIRouter() + + +class BootstrapStatusResponse(BaseModel): + bootstrapped: bool + + +class BootstrapAdminRequest(BaseModel): + username: str = Field("admin", min_length=3, max_length=64) + password: str = Field(..., min_length=8, max_length=128) + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=1, max_length=64) + password: str = Field(..., min_length=1, max_length=128) + + +class MeResponse(BaseModel): + id: str + username: str + role: str + status: str + must_reset_password: bool + created_at: str | None = None + updated_at: str | None = None + last_login_at: str | None = None + + +def _to_me_response(user) -> MeResponse: + return MeResponse( + id=user.id, + username=user.username, + role=user.role, + status=user.status, + must_reset_password=user.must_reset_password, + created_at=getattr(user, "created_at", None), + updated_at=getattr(user, "updated_at", None), + last_login_at=getattr(user, "last_login_at", None), + ) + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(..., min_length=1, max_length=128) + new_password: str = Field(..., min_length=8, max_length=128) + + +class ResetOwnPasswordResponse(BaseModel): + success: bool + temporary_password: str | None = None + must_reset_password: bool + + +@router.get("/bootstrap-status", response_model=BootstrapStatusResponse, summary="获取本地账号初始化状态") +async def bootstrap_status() -> BootstrapStatusResponse: + status_obj = await AuthService.get_bootstrap_status() + return BootstrapStatusResponse(**status_obj) + + +@router.post("/bootstrap-admin", response_model=MeResponse, summary="初始化管理员账号") +async def bootstrap_admin(payload: BootstrapAdminRequest, response: Response, request: Request) -> MeResponse: + try: + await AuthService.bootstrap_admin(payload.username, payload.password) + user, session_id = await AuthService.login( + payload.username, + payload.password, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + set_session_cookie(response, session_id, secure=should_use_secure_cookie(request)) + return _to_me_response(user) + + +@router.post("/login", response_model=MeResponse, summary="登录本地账号") +async def login(payload: LoginRequest, response: Response, request: Request) -> MeResponse: + try: + user, session_id = await AuthService.login( + payload.username, + payload.password, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + set_session_cookie(response, session_id, secure=should_use_secure_cookie(request)) + return _to_me_response(user) + + +@router.post("/logout", summary="退出登录") +async def logout(response: Response, request: Request) -> dict: + require_user(request) + session_id = request.cookies.get("flocks_session") + if session_id: + await AuthService.revoke_session(session_id) + clear_session_cookie(response) + return {"success": True} + + +@router.get("/me", response_model=MeResponse, summary="获取当前登录用户") +async def me(request: Request) -> MeResponse: + user = require_user(request) + full_user = await AuthService.get_user_by_id(user.id) + return _to_me_response(full_user or user) + + +@router.post("/change-password", summary="修改当前用户密码") +async def change_password(payload: ChangePasswordRequest, response: Response, request: Request) -> dict: + user = require_user(request) + try: + await AuthService.change_password( + user=user, + current_password=payload.current_password, + new_password=payload.new_password, + ) + _, session_id = await AuthService.login( + user.username, + payload.new_password, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + set_session_cookie(response, session_id, secure=should_use_secure_cookie(request)) + return {"success": True} + + +@router.post("/reset-password", response_model=ResetOwnPasswordResponse, summary="重置当前用户密码") +async def reset_own_password(response: Response, request: Request) -> ResetOwnPasswordResponse: + user = require_user(request) + import secrets + from datetime import UTC, datetime, timedelta + + new_password = secrets.token_urlsafe(10) + expires_at = (datetime.now(UTC) + timedelta(hours=24)).isoformat() + try: + await AuthService.set_password( + target_user_id=user.id, + new_password=new_password, + must_reset_password=True, + temp_password_expires_at=expires_at, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + clear_session_cookie(response) + return ResetOwnPasswordResponse( + success=True, + temporary_password=new_password, + must_reset_password=True, + ) diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index a9617207f..6fdf80ddc 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -10,14 +10,14 @@ import json import time from typing import List, Optional, Any, Dict, Literal, Union, Tuple -from fastapi import APIRouter, HTTPException, status, Query +from fastapi import APIRouter, HTTPException, status, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, ConfigDict from flocks.session.session import Session, SessionInfo as SessionModel from flocks.utils.log import Log from flocks.utils.json_repair import parse_json_robust, repair_truncated_json - +from flocks.server.auth import require_user router = APIRouter() log = Log.create(service="session-routes") @@ -104,6 +104,13 @@ class SessionResponse(BaseModel): permission: Optional[List[Dict[str, Any]]] = Field(None, description="Permission rules") revert: Optional[Dict[str, Any]] = Field(None, description="Revert state") category: str = Field("user", description="Session category: user or task") + ownerUserID: Optional[str] = Field(None, description="Session owner user id") + visibility: str = Field("private", description="Session visibility: private or team_shared") + sharedBy: Optional[str] = Field(None, description="User id who shared this session") + sharedAt: Optional[int] = Field(None, description="Share timestamp") + canShare: bool = Field(False, description="Whether current user can share this session") + canDelete: bool = Field(False, description="Whether current user can delete this session") + canUnshare: bool = Field(False, description="Whether current user can stop sharing this session") def _session_to_response(session: SessionModel) -> SessionResponse: @@ -113,6 +120,21 @@ def _session_to_response(session: SessionModel) -> SessionResponse: Note: agent/model/provider are NOT included at session level. They are retrieved from the latest user message in the session. """ + current_user = None + try: + from flocks.auth.context import get_current_auth_user + + current_user = get_current_auth_user() + except Exception: + current_user = None + + is_admin = bool(current_user and current_user.role == "admin") + is_owner = bool(current_user and Session._is_owned_by_auth_user(session, current_user)) + can_delete = is_admin or is_owner + # Share/unshare are intentionally restricted to the session creator/owner only. + can_share = is_owner + can_unshare = is_owner and session.visibility == "team_shared" + return SessionResponse( id=session.id, slug=session.slug, @@ -132,6 +154,13 @@ def _session_to_response(session: SessionModel) -> SessionResponse: revert=session.revert.model_dump(by_alias=True) if session.revert else None, permission=[p.model_dump() for p in session.permission] if session.permission else None, category=session.category, + ownerUserID=session.owner_user_id, + visibility=session.visibility, + sharedBy=session.shared_by, + sharedAt=session.shared_at, + canShare=can_share, + canDelete=can_delete, + canUnshare=can_unshare, ) @@ -141,6 +170,18 @@ def _is_hidden_from_session_manager(session: SessionModel) -> bool: return bool(metadata.get("hideFromSessionManager")) +def _can_manage_shared_session(current_user, session: SessionModel) -> bool: + if current_user.role == "admin": + return True + if Session._is_owned_by_auth_user(session, current_user): + return True + return bool(session.shared_by and current_user.id == session.shared_by) + + +def _can_toggle_session_share(current_user, session: SessionModel) -> bool: + return Session._is_owned_by_auth_user(session, current_user) + + # ============================================================================= # Session CRUD Routes # ============================================================================= @@ -183,6 +224,7 @@ async def get_session_status() -> Dict[str, Any]: description="Get a list of all sessions, sorted by most recently updated", ) async def list_sessions( + request: Request, directory: Optional[str] = Query(None, description="Filter by project directory"), roots: Optional[bool] = Query(None, description="Only return root sessions (no parentID)"), start: Optional[int] = Query(None, description="Filter sessions updated on or after this timestamp"), @@ -191,6 +233,7 @@ async def list_sessions( category: Optional[str] = Query(None, description="Filter by category: user or task"), ) -> List[SessionResponse]: """List all sessions with optional filters""" + _current_user = require_user(request) all_sessions = await Session.list_all() filtered = [] @@ -229,8 +272,9 @@ async def list_sessions( summary="Create session", description="Create a new session", ) -async def create_session(request: Optional[SessionCreateRequest] = None) -> SessionResponse: +async def create_session(http_request: Request, request: Optional[SessionCreateRequest] = None) -> SessionResponse: """Create a new session""" + current_user = require_user(http_request) import os if request is None: @@ -293,9 +337,10 @@ async def create_session(request: Optional[SessionCreateRequest] = None) -> Sess title=request.title, parent_id=request.parentID, permission=permission, + owner_user_id=current_user.id, **({"category": request.category} if request.category else {}), ) - + log.info("session.created", {"session_id": session.id}) return _session_to_response(session) @@ -308,8 +353,9 @@ async def create_session(request: Optional[SessionCreateRequest] = None) -> Sess summary="Get session", description="Get session by ID", ) -async def get_session(sessionID: str) -> SessionResponse: +async def get_session(sessionID: str, request: Request) -> SessionResponse: """Get session by ID""" + _current_user = require_user(request) session = await Session.get_by_id(sessionID) if not session: @@ -356,10 +402,17 @@ class TodoInfo(BaseModel): summary="Get session todos", description="Get the todo list for a session", ) -async def get_session_todos(sessionID: str) -> List[TodoInfo]: +async def get_session_todos(sessionID: str, request: Request) -> List[TodoInfo]: """Get session todos""" from flocks.storage.storage import Storage - + _current_user = require_user(request) + session = await Session.get_by_id(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found" + ) + try: todos = await Storage.read(["todo", sessionID]) if todos is None: @@ -376,11 +429,18 @@ async def get_session_todos(sessionID: str) -> List[TodoInfo]: summary="Update session todos", description="Update the todo list for a session", ) -async def update_session_todos(sessionID: str, todos: List[TodoInfo]) -> List[TodoInfo]: +async def update_session_todos(sessionID: str, todos: List[TodoInfo], request: Request) -> List[TodoInfo]: """Update session todos""" from flocks.storage.storage import Storage from flocks.server.routes.event import publish_event - + _current_user = require_user(request) + session = await Session.get_by_id(sessionID) + if not session: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session {sessionID} not found" + ) + try: await Storage.write(["todo", sessionID], [t.model_dump() for t in todos]) @@ -401,8 +461,9 @@ async def update_session_todos(sessionID: str, todos: List[TodoInfo]) -> List[To summary="Delete session", description="Delete session by ID", ) -async def delete_session(sessionID: str) -> bool: +async def delete_session(sessionID: str, request: Request) -> bool: """Delete session by ID (returns true)""" + current_user = require_user(request) session = await Session.get_by_id(sessionID) if not session: @@ -411,6 +472,13 @@ async def delete_session(sessionID: str) -> bool: detail=f"Session {sessionID} not found" ) + if session.visibility == "team_shared": + if not _can_manage_shared_session(current_user, session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员或共享发起人可删除共享会话") + else: + if current_user.role != "admin" and not Session._is_owned_by_auth_user(session, current_user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员或会话所有者可删除会话") + await Session.delete(session.project_id, sessionID) log.info("session.deleted", {"session_id": sessionID}) return True @@ -603,8 +671,9 @@ async def fork_session(sessionID: str, request: Optional[ForkRequest] = None) -> summary="Share session", description="Create a shareable link for the session", ) -async def share_session(sessionID: str) -> SessionResponse: +async def share_session(sessionID: str, request: Request) -> SessionResponse: """Share session""" + current_user = require_user(request) session = await Session.get_by_id(sessionID) if not session: raise HTTPException( @@ -612,9 +681,11 @@ async def share_session(sessionID: str) -> SessionResponse: detail=f"Session {sessionID} not found" ) + if not _can_toggle_session_share(current_user, session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅会话创建者可共享会话") + await Session.share(session.project_id, sessionID) updated = await Session.get_by_id(sessionID) - log.info("session.shared", {"session_id": sessionID}) return _session_to_response(updated) @@ -655,8 +726,9 @@ async def get_session_diff( summary="Unshare session", description="Remove the shareable link for the session", ) -async def unshare_session(sessionID: str) -> SessionResponse: +async def unshare_session(sessionID: str, request: Request) -> SessionResponse: """Unshare session""" + current_user = require_user(request) session = await Session.get_by_id(sessionID) if not session: raise HTTPException( @@ -664,9 +736,11 @@ async def unshare_session(sessionID: str) -> SessionResponse: detail=f"Session {sessionID} not found" ) + if not _can_toggle_session_share(current_user, session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅会话创建者可停止共享") + await Session.unshare(session.project_id, sessionID) updated = await Session.get_by_id(sessionID) - log.info("session.unshared", {"session_id": sessionID}) return _session_to_response(updated) diff --git a/flocks/session/session.py b/flocks/session/session.py index 440547d5c..fe2d25478 100644 --- a/flocks/session/session.py +++ b/flocks/session/session.py @@ -7,6 +7,7 @@ import contextvars import re +import secrets from typing import List, Dict, Any, Optional from datetime import datetime from pydantic import BaseModel, Field, ConfigDict @@ -98,6 +99,13 @@ class SessionInfo(BaseModel): # Session hierarchy parent_id: Optional[str] = Field(None, alias="parentID", description="Parent session for branching") + + # Local account ownership and visibility + owner_user_id: Optional[str] = Field(None, alias="ownerUserID", description="Owner local user id") + owner_username: Optional[str] = Field(None, alias="ownerUsername", description="Owner local username") + visibility: str = Field("private", description="Session visibility: private or team_shared") + shared_by: Optional[str] = Field(None, alias="sharedBy", description="User id who enabled sharing") + shared_at: Optional[int] = Field(None, alias="sharedAt", description="Share timestamp (ms)") # Summary and share summary: Optional[SessionChangeStats] = Field(None, description="File change summary") @@ -144,6 +152,46 @@ def _sort_sessions(sessions: List[SessionInfo]) -> List[SessionInfo]: """Return sessions sorted by most recently updated.""" return sorted(sessions, key=lambda s: s.time.updated, reverse=True) + @staticmethod + def _is_accessible_to_current_user(session: SessionInfo) -> bool: + """ + Check session visibility against request auth context. + + If no auth context exists (CLI/internal runtime), keep backward-compatible behavior. + """ + try: + from flocks.auth.context import get_current_auth_user + + auth_user = get_current_auth_user() + except Exception: + auth_user = None + + if auth_user is None: + return True + if auth_user.role == "admin": + return True + + if session.visibility == "team_shared": + return True + + # private session: owner-only + if Session._is_owned_by_auth_user(session, auth_user): + return True + + # Legacy sessions without owner should not be exposed to members. + return False + + @staticmethod + def _is_owned_by_auth_user(session: SessionInfo, auth_user) -> bool: + """Match session ownership by stable user id or retained username.""" + if auth_user is None: + return False + if session.owner_user_id and session.owner_user_id == auth_user.id: + return True + if session.owner_username and session.owner_username == auth_user.username: + return True + return False + @classmethod def _sync_list_cache(cls, session: SessionInfo) -> None: """Keep the in-memory list cache aligned with session mutations.""" @@ -257,6 +305,18 @@ async def create( kwargs["memory_enabled"] = bool(getattr(memory_cfg, "enabled")) except Exception as e: log.warn("session.memory.default.error", {"error": str(e)}) + + # Bind ownership from current auth context unless explicitly provided. + if "owner_user_id" not in kwargs or "owner_username" not in kwargs: + try: + from flocks.auth.context import get_current_auth_user + + current_user = get_current_auth_user() + if current_user: + kwargs.setdefault("owner_user_id", current_user.id) + kwargs.setdefault("owner_username", current_user.username) + except Exception: + pass session = SessionInfo( project_id=project_id, @@ -339,6 +399,8 @@ async def get(cls, project_id: str, session_id: str) -> Optional[SessionInfo]: # Don't return deleted sessions if session and session.status == "deleted": return None + if session and not cls._is_accessible_to_current_user(session): + return None return session except Exception as e: log.warn("session.get.error", {"error": str(e), "id": session_id}) @@ -363,6 +425,8 @@ async def get_by_id(cls, session_id: str) -> Optional[SessionInfo]: if cls._all_sessions_cache is not None: cached = next((s for s in cls._all_sessions_cache if s.id == session_id), None) if cached: + if not cls._is_accessible_to_current_user(cached): + return None return cached # Fast path: check in-memory index @@ -370,6 +434,8 @@ async def get_by_id(cls, session_id: str) -> Optional[SessionInfo]: if cached_key: session = await Storage.get(cached_key, SessionInfo) if session and session.status != "deleted": + if not cls._is_accessible_to_current_user(session): + return None return session # Index is stale — remove and fall through cls._id_index.pop(session_id, None) @@ -382,6 +448,8 @@ async def get_by_id(cls, session_id: str) -> Optional[SessionInfo]: try: session = await Storage.get(key, SessionInfo) if session and session.status != "deleted": + if not cls._is_accessible_to_current_user(session): + return None cls._id_index[session_id] = key return session except Exception as _e: @@ -406,7 +474,7 @@ async def list(cls, project_id: str) -> List[SessionInfo]: """ try: if cls._all_sessions_cache is not None: - return [s for s in cls._all_sessions_cache if s.project_id == project_id] + return [s for s in cls._all_sessions_cache if s.project_id == project_id and cls._is_accessible_to_current_user(s)] entries = await Storage.list_entries(prefix=f"session:{project_id}:", model=SessionInfo) sessions = [] @@ -414,7 +482,8 @@ async def list(cls, project_id: str) -> List[SessionInfo]: for key, session in entries: try: if session.status != "deleted": - sessions.append(session) + if cls._is_accessible_to_current_user(session): + sessions.append(session) cls._id_index[session.id] = key except Exception as e: log.warn("session.parse.error", {"key": key, "error": str(e)}) @@ -436,7 +505,7 @@ async def list_all(cls) -> List[SessionInfo]: """ try: if cls._all_sessions_cache is not None: - return list(cls._all_sessions_cache) + return [s for s in cls._all_sessions_cache if cls._is_accessible_to_current_user(s)] entries = await Storage.list_entries(prefix="session:", model=SessionInfo) sessions = [] @@ -450,7 +519,7 @@ async def list_all(cls) -> List[SessionInfo]: log.warn("session.parse.error", {"key": key, "error": str(e)}) cls._all_sessions_cache = cls._sort_sessions(sessions) - return list(cls._all_sessions_cache) + return [s for s in cls._all_sessions_cache if cls._is_accessible_to_current_user(s)] except Exception as e: log.error("session.list_all.error", {"error": str(e)}) return [] @@ -481,6 +550,10 @@ async def update( alias_map = { "project_id": "projectID", "parent_id": "parentID", + "owner_user_id": "ownerUserID", + "owner_username": "ownerUsername", + "shared_by": "sharedBy", + "shared_at": "sharedAt", } # Update fields. @@ -600,6 +673,32 @@ async def delete(cls, project_id: str, session_id: str) -> bool: log.warn("session.deleted.event_error", {"error": str(e)}) return True + + @classmethod + async def retain_deleted_user_sessions(cls, user_id: str, username: str) -> int: + """ + Detach session ownership from a deleted user id while preserving username ownership. + + This allows a newly created account with the same username to regain access + to historical private sessions. + """ + entries = await Storage.list_entries(prefix="session:", model=SessionInfo) + migrated = 0 + + for _key, session in entries: + if session.status == "deleted": + continue + if session.owner_user_id != user_id: + continue + await cls.update( + project_id=session.project_id, + session_id=session.id, + owner_user_id=_UNSET, + owner_username=username, + ) + migrated += 1 + + return migrated @classmethod async def archive(cls, project_id: str, session_id: str) -> bool: @@ -678,10 +777,26 @@ async def share(cls, project_id: str, session_id: str) -> Optional[SessionShare] # For now, create a placeholder share_info = SessionShare( url=f"https://share.example.com/s/{session_id[:8]}", - secret=Identifier.ascending("secret")[:16], + secret=secrets.token_urlsafe(12), ) await cls.update(project_id, session_id, share=share_info.model_dump()) + shared_by = None + try: + from flocks.auth.context import get_current_auth_user + + current_user = get_current_auth_user() + shared_by = current_user.id if current_user else None + except Exception: + shared_by = None + + await cls.update( + project_id, + session_id, + visibility="team_shared", + shared_by=shared_by, + shared_at=int(datetime.now().timestamp() * 1000), + ) # Store share secret separately await Storage.set(f"share:{session_id}", share_info.model_dump(), "share") @@ -707,7 +822,14 @@ async def unshare(cls, project_id: str, session_id: str) -> bool: """ try: await Storage.delete(f"share:{session_id}") - await cls.update(project_id, session_id, share=None) + await cls.update( + project_id, + session_id, + share=_UNSET, + visibility="private", + shared_by=_UNSET, + shared_at=_UNSET, + ) log.info("session.unshared", {"id": session_id}) return True @@ -728,7 +850,7 @@ async def get_share(cls, project_id: str, session_id: str) -> Optional[SessionSh ShareInfo or None """ try: - data = await Storage.get(f"share:{session_id}", dict) + data = await Storage.get(f"share:{session_id}") return SessionShare(**data) if data else None except Exception as _e: log.debug("session.share.get_failed", {"session_id": session_id, "error": str(_e)}) diff --git a/flocks/utils/id.py b/flocks/utils/id.py index 7257ac0db..e61a2b60e 100644 --- a/flocks/utils/id.py +++ b/flocks/utils/id.py @@ -17,6 +17,7 @@ "message", # msg "permission", # per "question", # que + "audit", # aud "user", # usr "part", # prt "pty", # pty @@ -46,6 +47,7 @@ class Identifier: "message": "msg", "permission": "per", "question": "que", + "audit": "aud", "user": "usr", "part": "prt", "pty": "pty", diff --git a/tests/server/routes/test_admin_users_routes.py b/tests/server/routes/test_admin_users_routes.py new file mode 100644 index 000000000..997274262 --- /dev/null +++ b/tests/server/routes/test_admin_users_routes.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_admin_routes_list_users(client: AsyncClient): + response = await client.get("/api/admin/users") + assert response.status_code == 200, response.text + users = response.json() + assert isinstance(users, list) + assert len(users) >= 1 + admin_user = users[0] + assert admin_user["role"] == "admin" + + +@pytest.mark.asyncio +async def test_admin_routes_reset_password(client: AsyncClient): + users = (await client.get("/api/admin/users")).json() + assert users + user_id = users[0]["id"] + + reset_response = await client.post( + f"/api/admin/users/{user_id}/reset-password", + json={"force_reset": True}, + ) + assert reset_response.status_code == 200, reset_response.text + assert reset_response.json()["must_reset_password"] is True + assert reset_response.json()["temporary_password"] + + +@pytest.mark.asyncio +async def test_admin_routes_create_user_not_allowed(client: AsyncClient): + response = await client.post( + "/api/admin/users", + json={ + "username": "newuser", + "password": "Password123!", + "role": "member", + }, + ) + assert response.status_code == 405, response.text + + +@pytest.mark.asyncio +async def test_admin_routes_audit_logs_not_available(client: AsyncClient): + response = await client.get("/api/admin/audit-logs") + assert response.status_code == 404, response.text + + +@pytest.mark.asyncio +async def test_admin_routes_delete_user_not_allowed(client: AsyncClient): + users = (await client.get("/api/admin/users")).json() + assert users + user_id = users[0]["id"] + + response = await client.delete(f"/api/admin/users/{user_id}") + assert response.status_code == 404, response.text diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 129359dd6..6b61f5f0f 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -15,6 +15,8 @@ import pytest from fastapi import HTTPException, status from httpx import AsyncClient +from flocks.auth.context import AuthUser, reset_current_auth_user, set_current_auth_user +from flocks.session.session import Session # =========================================================================== # CRUD @@ -172,6 +174,84 @@ async def test_send_message_noReply(self, client: AsyncClient, session_id: str): for m in messages ) + +# =========================================================================== +# Share permissions +# =========================================================================== + +class TestSessionSharePermissions: + @pytest.mark.asyncio + async def test_only_owner_can_share_and_unshare( + self, + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + ): + from flocks.server.routes import session as session_routes + + owner = AuthUser(id="usr_owner", username="owner", role="member", status="active") + viewer = AuthUser(id="usr_viewer", username="viewer", role="admin", status="active") + session = await Session.create( + project_id="share_permissions", + directory="/tmp", + title="owner-only-share", + owner_user_id=owner.id, + owner_username=owner.username, + ) + + monkeypatch.setattr(session_routes, "require_user", lambda _request: viewer) + forbidden_share = await client.post(f"/api/session/{session.id}/share") + assert forbidden_share.status_code == status.HTTP_403_FORBIDDEN + assert "仅会话创建者可共享会话" in forbidden_share.text + + monkeypatch.setattr(session_routes, "require_user", lambda _request: owner) + share_resp = await client.post(f"/api/session/{session.id}/share") + assert share_resp.status_code == status.HTTP_200_OK + assert share_resp.json()["visibility"] == "team_shared" + + monkeypatch.setattr(session_routes, "require_user", lambda _request: viewer) + forbidden_unshare = await client.delete(f"/api/session/{session.id}/share") + assert forbidden_unshare.status_code == status.HTTP_403_FORBIDDEN + assert "仅会话创建者可停止共享" in forbidden_unshare.text + + monkeypatch.setattr(session_routes, "require_user", lambda _request: owner) + unshare_resp = await client.delete(f"/api/session/{session.id}/share") + assert unshare_resp.status_code == status.HTTP_200_OK + assert unshare_resp.json()["visibility"] == "private" + + @pytest.mark.asyncio + async def test_session_response_share_flags_follow_owner_only(self): + from flocks.server.routes.session import _session_to_response + + owner = AuthUser(id="usr_owner", username="owner", role="member", status="active") + viewer = AuthUser(id="usr_viewer", username="viewer", role="admin", status="active") + token = set_current_auth_user(owner) + try: + session = await Session.create( + project_id="share_flags", + directory="/tmp", + title="shared-session", + ) + await Session.share("share_flags", session.id) + shared = await Session.get("share_flags", session.id) + assert shared is not None + owner_response = _session_to_response(shared) + assert owner_response.canShare is True + assert owner_response.canUnshare is True + finally: + reset_current_auth_user(token) + + token = set_current_auth_user(viewer) + try: + shared = await Session.get("share_flags", session.id) + assert shared is not None + viewer_response = _session_to_response(shared) + assert viewer_response.canShare is False + assert viewer_response.canUnshare is False + finally: + reset_current_auth_user(token) + + +class TestSessionMessagesRemaining: @pytest.mark.asyncio async def test_send_message_empty_parts_returns_success( self, client: AsyncClient, session_id: str diff --git a/tests/server/test_auth_compat.py b/tests/server/test_auth_compat.py new file mode 100644 index 000000000..6649db15d --- /dev/null +++ b/tests/server/test_auth_compat.py @@ -0,0 +1,179 @@ +from starlette.requests import Request +from fastapi import HTTPException +import pytest + +from flocks.auth.context import AuthUser +from flocks.server import auth as auth_module + + +class _FakeSecrets: + def __init__(self, values: dict[str, str] | None = None): + self.values = values or {} + + def get(self, key: str): + return self.values.get(key) + + +class _FakeLocalUser: + def __init__(self, *, must_reset_password: bool = False): + self.must_reset_password = must_reset_password + + def to_auth_user(self) -> AuthUser: + return AuthUser( + id="usr_test", + username="test-user", + role="member", + status="active", + must_reset_password=self.must_reset_password, + ) + + +def _make_request( + *, + headers: dict[str, str] | None = None, + client_host: str = "127.0.0.1", + path: str = "/api/session", +) -> Request: + normalized_headers = [] + for key, value in (headers or {}).items(): + normalized_headers.append((key.lower().encode("latin-1"), value.encode("latin-1"))) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode("latin-1"), + "query_string": b"", + "headers": normalized_headers, + "client": (client_host, 12345), + "server": ("127.0.0.1", 8000), + } + return Request(scope) + + +def test_read_api_token_from_authorization_header(): + request = _make_request(headers={"authorization": "Bearer test-token"}) + assert auth_module._read_api_token_from_request(request) == "test-token" + + +def test_read_api_token_from_custom_header(): + request = _make_request(headers={"x-flocks-api-token": "custom-token"}) + assert auth_module._read_api_token_from_request(request) == "custom-token" + + +def test_is_valid_api_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({auth_module.API_TOKEN_SECRET_ID: "abc123"})) + assert auth_module._is_valid_api_token("abc123") is True + assert auth_module._is_valid_api_token("wrong") is False + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_non_browser_accepts_valid_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({auth_module.API_TOKEN_SECRET_ID: "abc123"})) + request = _make_request(headers={"user-agent": "curl/8.0", "authorization": "Bearer abc123"}) + _, token, user = await auth_module.apply_auth_for_request(request) + try: + assert user is not None + assert user.username == "api-token-service" + finally: + auth_module.clear_auth_context(token) + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_non_browser_loopback_allows_without_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({auth_module.API_TOKEN_SECRET_ID: "abc123"})) + request = _make_request(headers={"user-agent": "curl/8.0"}) + _, token, user = await auth_module.apply_auth_for_request(request) + try: + assert user is not None + assert user.username == "local-service" + finally: + auth_module.clear_auth_context(token) + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_non_browser_remote_rejects_missing_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({auth_module.API_TOKEN_SECRET_ID: "abc123"})) + request = _make_request(headers={"user-agent": "curl/8.0"}, client_host="10.0.0.2") + with pytest.raises(HTTPException) as exc_info: + await auth_module.apply_auth_for_request(request) + assert exc_info.value.status_code == 401 + assert "Bearer API Token" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_non_browser_remote_rejects_invalid_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({auth_module.API_TOKEN_SECRET_ID: "abc123"})) + request = _make_request( + headers={"user-agent": "curl/8.0", "authorization": "Bearer wrong"}, + client_host="10.0.0.2", + ) + with pytest.raises(HTTPException) as exc_info: + await auth_module.apply_auth_for_request(request) + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_non_browser_remote_rejects_when_no_stored_token(monkeypatch): + monkeypatch.setattr(auth_module, "get_secret_manager", lambda: _FakeSecrets({})) + request = _make_request(headers={"user-agent": "curl/8.0"}, client_host="10.0.0.2") + with pytest.raises(HTTPException) as exc_info: + await auth_module.apply_auth_for_request(request) + assert exc_info.value.status_code == 401 + assert auth_module.API_TOKEN_SECRET_ID in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_requires_password_reset_before_access(monkeypatch): + async def _has_users(): + return True + + async def _get_user_by_session_id(_session_id: str): + return _FakeLocalUser(must_reset_password=True) + + monkeypatch.setattr(auth_module.AuthService, "has_users", _has_users) + monkeypatch.setattr(auth_module.AuthService, "get_user_by_session_id", _get_user_by_session_id) + + request = _make_request( + headers={ + "user-agent": "Mozilla/5.0", + "origin": "http://localhost:5173", + "cookie": f"{auth_module.SESSION_COOKIE_NAME}=session-123", + }, + path="/api/session", + ) + with pytest.raises(HTTPException) as exc_info: + await auth_module.apply_auth_for_request(request) + + assert exc_info.value.status_code == 403 + assert "必须先修改密码" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_apply_auth_for_request_allows_password_reset_endpoints_when_required(monkeypatch): + async def _has_users(): + return True + + async def _get_user_by_session_id(_session_id: str): + return _FakeLocalUser(must_reset_password=True) + + monkeypatch.setattr(auth_module.AuthService, "has_users", _has_users) + monkeypatch.setattr(auth_module.AuthService, "get_user_by_session_id", _get_user_by_session_id) + + request = _make_request( + headers={ + "user-agent": "Mozilla/5.0", + "origin": "http://localhost:5173", + "cookie": f"{auth_module.SESSION_COOKIE_NAME}=session-123", + }, + path="/api/auth/change-password", + ) + _, token, user = await auth_module.apply_auth_for_request(request) + try: + assert user is not None + assert user.must_reset_password is True + finally: + auth_module.clear_auth_context(token) diff --git a/tests/session/test_session.py b/tests/session/test_session.py index 431bc3eba..9a0c20324 100644 --- a/tests/session/test_session.py +++ b/tests/session/test_session.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock import pytest +from flocks.auth.context import AuthUser, reset_current_auth_user, set_current_auth_user from flocks.session.session import Session from flocks.session.message import Message, MessageInfo, MessageRole, TokenUsage from flocks.session.callable_state import add_session_callable_tools, get_session_callable_tools @@ -173,6 +174,50 @@ async def test_session_update(): assert updated.title == "Updated Title" +@pytest.mark.asyncio +async def test_session_accessible_to_recreated_user_with_same_username(): + session = await Session.create( + project_id="test_project_username_owner", + directory="/test/dir", + title="Owned by Username", + owner_user_id="usr_old_owner", + owner_username="alice", + ) + + token = set_current_auth_user( + AuthUser( + id="usr_new_owner", + username="alice", + role="member", + status="active", + ) + ) + try: + sessions = await Session.list_all() + assert any(item.id == session.id for item in sessions) + finally: + reset_current_auth_user(token) + + +@pytest.mark.asyncio +async def test_retain_deleted_user_sessions_detaches_owner_id_and_preserves_username(): + session = await Session.create( + project_id="test_project_deleted_owner", + directory="/test/dir", + title="Deleted Owner", + owner_user_id="usr_deleted_owner", + owner_username="alice", + ) + + migrated = await Session.retain_deleted_user_sessions("usr_deleted_owner", "alice") + assert migrated == 1 + + stored = await Session.get("test_project_deleted_owner", session.id) + assert stored is not None + assert stored.owner_user_id is None + assert stored.owner_username == "alice" + + @pytest.mark.asyncio async def test_session_delete(): """Test session deletion""" diff --git a/tests/session/test_session_advanced.py b/tests/session/test_session_advanced.py index 598627539..e60d572be 100644 --- a/tests/session/test_session_advanced.py +++ b/tests/session/test_session_advanced.py @@ -78,26 +78,18 @@ class TestShareUnshare: @pytest.mark.asyncio async def test_share_creates_share_info(self): session = await _create(project_id="proj_share_1") - # Session.share() calls Identifier.ascending("secret") internally - # Mock or allow potential errors gracefully - try: - share_info = await Session.share("proj_share_1", session.id) - assert share_info is not None - assert isinstance(share_info, SessionShare) - assert share_info.url - assert len(share_info.url) > 5 - except (KeyError, Exception) as e: - if "secret" in str(e).lower() or "identifier" in str(type(e).__name__).lower(): - pytest.skip(f"Identifier namespace issue: {e}") - raise + share_info = await Session.share("proj_share_1", session.id) + assert share_info is not None + assert isinstance(share_info, SessionShare) + assert share_info.url + assert len(share_info.url) > 5 + assert share_info.secret + assert len(share_info.secret) >= 16 @pytest.mark.asyncio async def test_shared_session_has_share_field(self): session = await _create(project_id="proj_share_2") - try: - await Session.share("proj_share_2", session.id) - except KeyError: - pytest.skip("Identifier namespace issue with 'secret'") + await Session.share("proj_share_2", session.id) updated = await Session.get("proj_share_2", session.id) assert updated is not None assert updated.share is not None @@ -105,10 +97,7 @@ async def test_shared_session_has_share_field(self): @pytest.mark.asyncio async def test_unshare_clears_share_info(self): session = await _create(project_id="proj_share_3") - try: - await Session.share("proj_share_3", session.id) - except KeyError: - pytest.skip("Identifier namespace issue with 'secret'") + await Session.share("proj_share_3", session.id) await Session.unshare("proj_share_3", session.id) updated = await Session.get("proj_share_3", session.id) assert updated is not None @@ -117,10 +106,7 @@ async def test_unshare_clears_share_info(self): @pytest.mark.asyncio async def test_get_share_returns_share_info(self): session = await _create(project_id="proj_share_4") - try: - await Session.share("proj_share_4", session.id) - except KeyError: - pytest.skip("Identifier namespace issue with 'secret'") + await Session.share("proj_share_4", session.id) share = await Session.get_share("proj_share_4", session.id) assert share is not None assert share.url diff --git a/tests/utils/test_id_compatibility.py b/tests/utils/test_id_compatibility.py index 9cf9508a8..5230e04da 100644 --- a/tests/utils/test_id_compatibility.py +++ b/tests/utils/test_id_compatibility.py @@ -17,6 +17,7 @@ def test_prefix_mappings(self): "message": "msg", "permission": "per", "question": "que", + "audit": "aud", "user": "usr", "part": "prt", "pty": "pty", @@ -28,8 +29,10 @@ def test_prefix_mappings(self): "agent": "agt", "subtask": "stk", "event": "evt", + "tqref": "tqr", "task": "tsk", "texec": "txe", + "chbind": "chb", } assert Identifier._prefixes == expected_prefixes diff --git a/tui/sdk/client.ts b/tui/sdk/client.ts index 1595e01e7..a706eebae 100644 --- a/tui/sdk/client.ts +++ b/tui/sdk/client.ts @@ -1,10 +1,39 @@ export * from "./gen/types.gen.js" +import { readFileSync } from "fs" +import os from "os" +import path from "path" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { FlocksClient } from "./gen/sdk.gen.js" export { type Config as FlocksClientConfig, FlocksClient } +const API_TOKEN_SECRET_ID = "server_api_token" + +function getStoredApiToken(): string | undefined { + if (typeof process === "undefined") return undefined + const configDir = process.env.FLOCKS_CONFIG_DIR || path.join(os.homedir(), ".flocks", "config") + const secretFile = path.join(configDir, ".secret.json") + try { + const parsed = JSON.parse(readFileSync(secretFile, "utf-8")) as Record + const value = parsed[API_TOKEN_SECRET_ID] + if (typeof value !== "string") return undefined + return value.trim() || undefined + } catch { + return undefined + } +} + +function withAuthHeaders(config?: Config & { directory?: string }) { + const headers = new Headers(config?.headers as HeadersInit | undefined) + const apiToken = getStoredApiToken() + const hasAuth = headers.has("authorization") || headers.has("x-flocks-api-token") + if (apiToken && !hasAuth) { + headers.set("Authorization", `Bearer ${apiToken}`) + } + return headers +} + export function createFlocksClient(config?: Config & { directory?: string }) { if (!config?.fetch) { const customFetch: any = (req: any) => { @@ -18,12 +47,12 @@ export function createFlocksClient(config?: Config & { directory?: string }) { } } + const headers = withAuthHeaders(config) + if (config?.directory) { - config.headers = { - ...config.headers, - "x-flocks-directory": config.directory, - } + headers.set("x-flocks-directory", config.directory) } + config = { ...config, headers: Object.fromEntries(headers.entries()) } const client = createClient(config) return new FlocksClient({ client }) diff --git a/tui/sdk/v2/client.ts b/tui/sdk/v2/client.ts index e0079b620..a744f3f8d 100644 --- a/tui/sdk/v2/client.ts +++ b/tui/sdk/v2/client.ts @@ -1,10 +1,39 @@ export * from "./gen/types.gen.js" +import { readFileSync } from "fs" +import os from "os" +import path from "path" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { FlocksClient } from "./gen/sdk.gen.js" export { type Config as FlocksClientConfig, FlocksClient } +const API_TOKEN_SECRET_ID = "server_api_token" + +function getStoredApiToken(): string | undefined { + if (typeof process === "undefined") return undefined + const configDir = process.env.FLOCKS_CONFIG_DIR || path.join(os.homedir(), ".flocks", "config") + const secretFile = path.join(configDir, ".secret.json") + try { + const parsed = JSON.parse(readFileSync(secretFile, "utf-8")) as Record + const value = parsed[API_TOKEN_SECRET_ID] + if (typeof value !== "string") return undefined + return value.trim() || undefined + } catch { + return undefined + } +} + +function withAuthHeaders(config?: Config & { directory?: string }) { + const headers = new Headers(config?.headers as HeadersInit | undefined) + const apiToken = getStoredApiToken() + const hasAuth = headers.has("authorization") || headers.has("x-flocks-api-token") + if (apiToken && !hasAuth) { + headers.set("Authorization", `Bearer ${apiToken}`) + } + return headers +} + export function createFlocksClient(config?: Config & { directory?: string }) { if (!config?.fetch) { const customFetch: any = (req: any) => { @@ -18,14 +47,14 @@ export function createFlocksClient(config?: Config & { directory?: string }) { } } + const headers = withAuthHeaders(config) + if (config?.directory) { const isNonASCII = /[^\x00-\x7F]/.test(config.directory) const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory - config.headers = { - ...config.headers, - "x-flocks-directory": encodedDirectory, - } + headers.set("x-flocks-directory", encodedDirectory) } + config = { ...config, headers: Object.fromEntries(headers.entries()) } const client = createClient(config) return new FlocksClient({ client }) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 126b50e4c..c7a1f44a9 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -3,14 +3,17 @@ import { Routes } from './routes' import { ToastProvider } from './components/common/Toast' import { ConfirmProvider } from './components/common/ConfirmDialog' import { BackendStatusBanner } from './components/common/BackendStatusBanner' +import { AuthProvider } from './contexts/AuthContext' export default function App() { return ( - - + + + + diff --git a/webui/src/api/auth.ts b/webui/src/api/auth.ts new file mode 100644 index 000000000..17c37c170 --- /dev/null +++ b/webui/src/api/auth.ts @@ -0,0 +1,57 @@ +import client from './client'; + +export interface BootstrapStatus { + bootstrapped: boolean; +} + +export interface LocalUser { + id: string; + username: string; + role: 'admin' | 'member'; + status: 'active' | 'disabled'; + must_reset_password: boolean; + created_at?: string | null; + updated_at?: string | null; + last_login_at?: string | null; +} + +export interface ResetPasswordResult { + success: boolean; + temporary_password?: string | null; + must_reset_password: boolean; +} + +export const authApi = { + bootstrapStatus: async (): Promise => { + const response = await client.get('/api/auth/bootstrap-status'); + return response.data; + }, + + bootstrapAdmin: async (payload: { username: string; password: string }): Promise => { + const response = await client.post('/api/auth/bootstrap-admin', payload); + return response.data; + }, + + login: async (payload: { username: string; password: string }): Promise => { + const response = await client.post('/api/auth/login', payload); + return response.data; + }, + + me: async (): Promise => { + const response = await client.get('/api/auth/me'); + return response.data; + }, + + logout: async (): Promise => { + await client.post('/api/auth/logout'); + }, + + changePassword: async (payload: { current_password: string; new_password: string }): Promise => { + await client.post('/api/auth/change-password', payload); + }, + + resetPassword: async (): Promise => { + const response = await client.post('/api/auth/reset-password'); + return response.data; + }, +}; diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts new file mode 100644 index 000000000..0e5dad403 --- /dev/null +++ b/webui/src/api/client.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { resolveApiBaseURL } from './client'; + +describe('resolveApiBaseURL', () => { + it('returns the configured URL when no current origin is provided', () => { + expect(resolveApiBaseURL('http://127.0.0.1:8000', undefined)).toBe('http://127.0.0.1:8000'); + }); + + it('keeps the configured URL when current origin already matches', () => { + expect(resolveApiBaseURL('http://127.0.0.1:8000', 'http://127.0.0.1:5173')).toBe('http://127.0.0.1:8000'); + }); + + it('rewrites loopback aliases to the current page host', () => { + expect(resolveApiBaseURL('http://127.0.0.1:8000', 'http://localhost:5173')).toBe('http://localhost:8000'); + expect(resolveApiBaseURL('http://localhost:9000', 'http://127.0.0.1:5173')).toBe('http://127.0.0.1:9000'); + }); + + it('does not rewrite non-loopback hosts', () => { + expect(resolveApiBaseURL('http://10.0.0.8:8000', 'http://localhost:5173')).toBe('http://10.0.0.8:8000'); + }); +}); diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index c92ab32c6..2138272dd 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -1,11 +1,44 @@ import axios from 'axios'; -// 部署时前后端同域,使用相对路径即可;开发时通过 .env 或 vite proxy 配置 -const baseURL = import.meta.env.VITE_API_BASE_URL || ''; +function isLoopbackHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; +} + +export function resolveApiBaseURL(configuredBaseURL: string, currentOrigin?: string): string { + if (!configuredBaseURL || !currentOrigin) { + return configuredBaseURL; + } + + try { + const configuredUrl = new URL(configuredBaseURL); + const currentUrl = new URL(currentOrigin); + + if ( + configuredUrl.origin !== currentUrl.origin && + isLoopbackHostname(configuredUrl.hostname) && + isLoopbackHostname(currentUrl.hostname) + ) { + configuredUrl.hostname = currentUrl.hostname; + return configuredUrl.toString().replace(/\/$/, ''); + } + + return configuredBaseURL; + } catch { + return configuredBaseURL; + } +} + +// 部署时前后端同域,使用相对路径即可;本地开发若混用 localhost/127.0.0.1, +// 这里会自动对齐到当前页面主机名,避免浏览器把登录 cookie 当成跨站请求。 +const baseURL = resolveApiBaseURL( + import.meta.env.VITE_API_BASE_URL || '', + typeof window !== 'undefined' ? window.location.origin : undefined, +); export const apiClient = axios.create({ baseURL, timeout: 30000, // 30 seconds - 缩短超时时间以更快发现连接问题 + withCredentials: true, headers: { 'Content-Type': 'application/json', }, @@ -30,6 +63,13 @@ apiClient.interceptors.response.use( (error) => { const status = error.response?.status; const url = error.config?.url || ''; + const isAuthEndpoint = + typeof url === 'string' && + ( + url.includes('/api/auth/login') || + url.includes('/api/auth/bootstrap-status') || + url.includes('/api/auth/bootstrap-admin') + ); const isExpectedMissingDefaultModel = status === 404 && typeof url === 'string' && url.includes('/api/default-model/resolved'); @@ -37,6 +77,13 @@ apiClient.interceptors.response.use( return Promise.reject(error); } + if (status === 401 && !isAuthEndpoint) { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('flocks:auth-expired')); + } + return Promise.reject(error); + } + // 统一错误处理 if (error.code === 'ECONNABORTED') { console.error('API Timeout:', error.config?.url); diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index 4801c9aa7..99e10083a 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -72,6 +72,16 @@ export const sessionApi = { return response.data; }, + share: async (sessionId: string) => { + const response = await client.post(`/api/session/${sessionId}/share`); + return response.data; + }, + + unshare: async (sessionId: string) => { + const response = await client.delete(`/api/session/${sessionId}/share`); + return response.data; + }, + /** * 清空会话消息 */ diff --git a/webui/src/components/common/PasswordInput.tsx b/webui/src/components/common/PasswordInput.tsx new file mode 100644 index 000000000..322162b22 --- /dev/null +++ b/webui/src/components/common/PasswordInput.tsx @@ -0,0 +1,38 @@ +import { useState, forwardRef } from 'react'; +import type { InputHTMLAttributes } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; + +type PasswordInputProps = Omit, 'type'>; + +const PasswordInput = forwardRef( + ({ className = '', ...rest }, ref) => { + const [visible, setVisible] = useState(false); + + const baseClass = + 'w-full border border-gray-300 rounded-lg px-3 py-2 pr-10 outline-none focus:border-blue-500'; + + return ( +
+ + +
+ ); + }, +); + +PasswordInput.displayName = 'PasswordInput'; + +export default PasswordInput; diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index d0a236f4c..4b5830310 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -30,7 +30,7 @@ import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; import { useReasoningToggle } from '@/hooks/useReasoningToggle'; import { usePendingQuestions, type PendingQuestion } from '@/hooks/usePendingQuestions'; import { sessionApi } from '@/api/session'; -import client from '@/api/client'; +import client, { getApiBase } from '@/api/client'; import { commandAPI, type Command } from '@/api/skill'; import { workspaceAPI } from '@/api/workspace'; import { copyText } from '@/utils/clipboard'; @@ -591,7 +591,7 @@ export default function SessionChat({ ); const { status: sseStatus } = useSSE({ - url: `${import.meta.env.VITE_API_BASE_URL || ''}/api/event`, + url: `${getApiBase()}/api/event`, onEvent: handleSSEEvent, onReconnect: () => { if (!sessionId) return; diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index d71125284..759cb37b1 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -16,6 +16,7 @@ import { FolderOpen, Sparkles, ArrowUpCircle, + Settings, } from 'lucide-react'; import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,7 +42,6 @@ export default function Layout() { const lastUpdateCheckAtRef = useRef(0); const checkingUpdateRef = useRef(false); const lastPromptedVersionRef = useRef(null); - // useLayoutEffect runs synchronously before paint, so there's no flash on initial load. // It also re-runs when the user navigates back to /, covering both cases in one place. useLayoutEffect(() => { @@ -153,6 +153,12 @@ export default function Layout() { { name: t('channels'), href: '/channels', icon: Radio }, ], }, + { + name: '系统配置', + items: [ + { name: '系统配置', href: '/config', icon: Settings }, + ], + }, ]; const isFullScreenPage = @@ -228,7 +234,8 @@ export default function Layout() { {collapsed &&
}
{section.items.map((item) => { - const isActive = location.pathname === item.href; + const isActive = location.pathname === item.href + || (item.href !== '/' && location.pathname.startsWith(`${item.href}/`)); return ( setShowUpdate(true)} - className="w-full mt-3 rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-3 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md" + className="mt-3 w-full rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 via-orange-50 to-rose-50 px-3 py-2 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md" > -
-
-
- - - -
-
- {t('updateAvailable')} -
-
- {latestVersion ? `v${latestVersion}` : t('newVersion')} -
-
-
-
- +
+ + {t('newVersion')} {latestVersion ? `v${latestVersion}` : ''} + + {t('updateNow')}
-
- - {currentVersion - ? t('currentVersionLabel', { version: currentVersion }) - : 'Flocks'} - - AI Native SecOps Platform +
+ {currentVersion + ? t('currentVersionLabel', { version: currentVersion }) + : 'Flocks'} +
+
+ AI Native SecOps Platform
) : ( diff --git a/webui/src/contexts/AuthContext.tsx b/webui/src/contexts/AuthContext.tsx new file mode 100644 index 000000000..ac3f67b88 --- /dev/null +++ b/webui/src/contexts/AuthContext.tsx @@ -0,0 +1,116 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { authApi, type LocalUser } from '@/api/auth'; + +interface AuthContextValue { + loading: boolean; + bootstrapped: boolean | null; + error: string | null; + user: LocalUser | null; + refresh: () => Promise; + login: (username: string, password: string) => Promise; + bootstrapAdmin: (username: string, password: string) => Promise; + logout: () => Promise; + changePassword: (currentPassword: string, newPassword: string) => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [loading, setLoading] = useState(true); + const [bootstrapped, setBootstrapped] = useState(null); + const [error, setError] = useState(null); + const [user, setUser] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const status = await authApi.bootstrapStatus(); + setBootstrapped(status.bootstrapped); + setError(null); + if (!status.bootstrapped) { + setUser(null); + return; + } + try { + const me = await authApi.me(); + setUser(me); + } catch (err: any) { + if (err?.response?.status === 401) { + setUser(null); + return; + } + setUser(null); + setError(err?.response?.data?.message || err?.response?.data?.detail || err?.message || '无法获取登录状态,请稍后重试'); + } + } catch (err: any) { + setBootstrapped(null); + setUser(null); + setError(err?.response?.data?.message || err?.response?.data?.detail || err?.message || '无法连接后端,请稍后重试'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + const onAuthExpired = () => { + setError(null); + setUser(null); + }; + window.addEventListener('flocks:auth-expired', onAuthExpired); + return () => window.removeEventListener('flocks:auth-expired', onAuthExpired); + }, []); + + const login = useCallback(async (username: string, password: string) => { + const me = await authApi.login({ username, password }); + setBootstrapped(true); + setError(null); + setUser(me); + }, []); + + const bootstrapAdmin = useCallback(async (username: string, password: string) => { + const me = await authApi.bootstrapAdmin({ username, password }); + setBootstrapped(true); + setError(null); + setUser(me); + }, []); + + const logout = useCallback(async () => { + await authApi.logout(); + setError(null); + setUser(null); + }, []); + + const changePassword = useCallback(async (currentPassword: string, newPassword: string) => { + await authApi.changePassword({ + current_password: currentPassword, + new_password: newPassword, + }); + await refresh(); + }, [refresh]); + + const value = useMemo(() => ({ + loading, + bootstrapped, + error, + user, + refresh, + login, + bootstrapAdmin, + logout, + changePassword, + }), [loading, bootstrapped, error, user, refresh, login, bootstrapAdmin, logout, changePassword]); + + return {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error('useAuth must be used within AuthProvider'); + } + return ctx; +} diff --git a/webui/src/hooks/useSSE.test.tsx b/webui/src/hooks/useSSE.test.tsx new file mode 100644 index 000000000..20121cdc1 --- /dev/null +++ b/webui/src/hooks/useSSE.test.tsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSSE } from './useSSE'; + +describe('useSSE', () => { + const eventSourceCtor = vi.fn(); + + class FakeEventSource { + url: string; + withCredentials: boolean; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + onopen: ((event: Event) => void) | null = null; + readyState = 0; + + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + + constructor(url: string, init?: EventSourceInit) { + this.url = url; + this.withCredentials = Boolean(init?.withCredentials); + eventSourceCtor(url, init); + } + + close() { + this.readyState = FakeEventSource.CLOSED; + } + + addEventListener() {} + removeEventListener() {} + dispatchEvent() { return true; } + } + + beforeEach(() => { + eventSourceCtor.mockClear(); + vi.stubGlobal('EventSource', FakeEventSource as unknown as typeof EventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('defaults to opening authenticated SSE connections', () => { + const { unmount } = renderHook(() => useSSE({ + url: 'http://127.0.0.1:8000/api/event', + onEvent: vi.fn(), + })); + + expect(eventSourceCtor).toHaveBeenCalledWith( + 'http://127.0.0.1:8000/api/event', + { withCredentials: true }, + ); + + unmount(); + }); + + it('allows callers to opt out of credentials explicitly', () => { + const { unmount } = renderHook(() => useSSE({ + url: '/public/events', + onEvent: vi.fn(), + withCredentials: false, + })); + + expect(eventSourceCtor).toHaveBeenCalledWith( + '/public/events', + { withCredentials: false }, + ); + + unmount(); + }); +}); diff --git a/webui/src/hooks/useSSE.ts b/webui/src/hooks/useSSE.ts index 9476fc7ea..58638ad21 100644 --- a/webui/src/hooks/useSSE.ts +++ b/webui/src/hooks/useSSE.ts @@ -11,6 +11,8 @@ export interface UseSSEOptions { onError?: (error: Event) => void; onReconnect?: () => void; enabled?: boolean; + /** 是否携带凭据(cookie),默认 true 以支持受保护 SSE 接口 */ + withCredentials?: boolean; /** 重连配置 */ reconnect?: { /** 是否启用自动重连,默认 true */ @@ -32,6 +34,7 @@ export function useSSE({ onError, onReconnect, enabled = true, + withCredentials = true, reconnect = {}, }: UseSSEOptions) { const eventSourceRef = useRef(null); @@ -88,7 +91,7 @@ export function useSSE({ console.log('[SSE] Creating EventSource connection to:', url); } setStatus('connecting'); - const eventSource = new EventSource(url); + const eventSource = new EventSource(url, { withCredentials }); eventSourceRef.current = eventSource; eventSource.onopen = () => { @@ -163,7 +166,7 @@ export function useSSE({ }, 30000); // 30秒后重试 } }; - }, [url, enabled, reconnectEnabled, maxRetries, getReconnectDelay, clearReconnectTimeout]); + }, [url, enabled, withCredentials, reconnectEnabled, maxRetries, getReconnectDelay, clearReconnectTimeout]); // 主 effect useEffect(() => { diff --git a/webui/src/pages/AdminUsers/index.tsx b/webui/src/pages/AdminUsers/index.tsx new file mode 100644 index 000000000..4367294e6 --- /dev/null +++ b/webui/src/pages/AdminUsers/index.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react'; +import { authApi } from '@/api/auth'; +import CopyButton from '@/components/common/CopyButton'; +import { useAuth } from '@/contexts/AuthContext'; +import { useToast } from '@/components/common/Toast'; +import { useConfirm } from '@/components/common/ConfirmDialog'; + +function formatDateTime(value?: string | null) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { hour12: false }); +} + +export default function AdminUsersPage() { + const { user } = useAuth(); + const toast = useToast(); + const confirm = useConfirm(); + const [resetCredential, setResetCredential] = useState<{ + username: string; + password: string; + } | null>(null); + + const closeResetCredentialModal = () => { + setResetCredential(null); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('flocks:auth-expired')); + } + }; + + const resetOwnPassword = async () => { + const confirmed = await confirm({ + title: '重置密码', + description: '确认重置当前账号密码吗?系统会清理当前登录态,并生成一次性密码供你重新登录。', + confirmText: '确认重置', + variant: 'warning', + }); + if (!confirmed) return; + try { + const result = await authApi.resetPassword(); + if (result.temporary_password && user) { + setResetCredential({ + username: user.username, + password: result.temporary_password, + }); + } else { + toast.success('密码已重置'); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('flocks:auth-expired')); + } + } + } catch (err: any) { + toast.error('重置失败', err?.response?.data?.detail || err?.message || '重置失败'); + } + }; + + return ( +
+
+

账号管理

+

管理当前管理员账号与密码。

+
+ +
+ + + + + + + + + + + {user && ( + + + + + + + )} + +
用户名角色最近登录操作
+
{user.username}
+
当前登录账号
+
管理员{formatDateTime(user.last_login_at)} + +
+
+ + {resetCredential && ( + <> +
+
+
+
+
+

一次性密码已生成

+

+ 当前账号已被重置,请先复制一次性密码,关闭后将返回登录页。 +

+
+ +
+
+
+
+
账号名
+
{resetCredential.username}
+
+
+
+
+
一次性密码
+
{resetCredential.password}
+
+ +
+
+
请先复制保存,关闭弹窗后将无法再次直接看到这串密码。
+
+
+ +
+
+
+
+ + )} +
+ ); +} diff --git a/webui/src/pages/Config/index.tsx b/webui/src/pages/Config/index.tsx index ce453dc42..01f810c64 100644 --- a/webui/src/pages/Config/index.tsx +++ b/webui/src/pages/Config/index.tsx @@ -1,121 +1,31 @@ -import { useState, useEffect } from 'react'; -import { Settings, Save, RotateCcw } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { Outlet } from 'react-router-dom'; +import { Settings } from 'lucide-react'; import PageHeader from '@/components/common/PageHeader'; -import LoadingSpinner from '@/components/common/LoadingSpinner'; -import client from '@/api/client'; +import { useAuth } from '@/contexts/AuthContext'; export default function ConfigPage() { - const { t } = useTranslation('config'); - const [config, setConfig] = useState>({}); - const [editedConfig, setEditedConfig] = useState(''); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - fetchConfig(); - }, []); - - const fetchConfig = async () => { - try { - setLoading(true); - const response = await client.get('/api/config'); - setConfig(response.data); - setEditedConfig(JSON.stringify(response.data, null, 2)); - } catch (err: any) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - const handleSave = async () => { - try { - const parsedConfig = JSON.parse(editedConfig); - setSaving(true); - await client.put('/api/config', parsedConfig); - setConfig(parsedConfig); - alert(t('editor.saved')); - } catch (err: any) { - if (err instanceof SyntaxError) { - alert(t('editor.jsonError')); - } else { - alert(`${t('editor.saveFailed')}: ${err.message}`); - } - } finally { - setSaving(false); - } - }; - - const handleReset = () => { - setEditedConfig(JSON.stringify(config, null, 2)); - }; - - if (loading) { - return ( -
- -
- ); - } - - if (error) { - return ( -
-
-

{error}

- -
-
- ); - } + const { logout } = useAuth(); return ( -
+
} + action={( + + )} /> -
-
-
-

{t('editor.title')}

-

{t('editor.description')}

-
-
- - -
-
- -
-