rebuild(stage-3): establish accounts permissions and secure settings

This commit is contained in:
leefer
2026-07-30 01:33:19 +08:00
parent 2ff35eb6df
commit f69972c3c0
31 changed files with 2742 additions and 18 deletions
+1
View File
@@ -34,6 +34,7 @@ npm.cmd run dev
- `APP_LOG_LEVEL``DEBUG``INFO``WARNING``ERROR``CRITICAL`
- `APP_HOST``APP_PORT`:监听地址和端口;迁移开发端口为8780。
- `APP_TIMEZONE`:固定为`Asia/Shanghai`,其他值会拒绝启动。
- `APP_ENCRYPTION_KEY`:Fernet密钥;生产环境必填。开发环境首次启动时只在数据目录生成本地密钥文件。
数据库维护:
+20
View File
@@ -5,6 +5,14 @@ from dataclasses import dataclass
from backend.bootstrap.settings import Settings
from backend.database.connection import Database
from backend.database.repositories.status import DatabaseStatusRepository
from backend.features.accounts.credentials import SystemCredentialService
from backend.features.accounts.model_pool import ModelPoolRepository, ModelPoolService
from backend.features.accounts.repository import AccountRepository, SystemCredentialRepository
from backend.features.accounts.service import (
AccountService,
MembershipService,
)
from backend.security import PasswordHasher, load_or_create_cipher
@dataclass(frozen=True, slots=True)
@@ -12,12 +20,24 @@ class ApplicationContainer:
settings: Settings
database: Database
database_status: DatabaseStatusRepository
accounts: AccountService
memberships: MembershipService
system_credentials: SystemCredentialService
model_pool: ModelPoolService
def build_container(settings: Settings) -> ApplicationContainer:
database = Database(settings.database_path)
account_repository = AccountRepository()
credential_repository = SystemCredentialRepository()
model_pool_repository = ModelPoolRepository()
cipher = load_or_create_cipher(settings)
return ApplicationContainer(
settings=settings,
database=database,
database_status=DatabaseStatusRepository(database),
accounts=AccountService(database, account_repository, PasswordHasher(), cipher),
memberships=MembershipService(database, account_repository),
system_credentials=SystemCredentialService(database, credential_repository, cipher),
model_pool=ModelPoolService(database, model_pool_repository, cipher),
)
+6
View File
@@ -38,6 +38,7 @@ class Settings:
log_level: str
host: str
port: int
encryption_key: str | None
timezone: str = "Asia/Shanghai"
@classmethod
@@ -60,6 +61,9 @@ class Settings:
log_level = os.getenv("APP_LOG_LEVEL", "INFO").strip().upper()
if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ConfigurationError("APP_LOG_LEVEL is invalid")
encryption_key = os.getenv("APP_ENCRYPTION_KEY", "").strip() or None
if environment == "production" and encryption_key is None:
raise ConfigurationError("APP_ENCRYPTION_KEY is required in production")
return cls(
environment=environment,
debug=environment == "development",
@@ -69,6 +73,7 @@ class Settings:
log_level=log_level,
host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1",
port=_parse_port(os.getenv("APP_PORT", "8780")),
encryption_key=encryption_key,
timezone=timezone,
)
@@ -84,4 +89,5 @@ class Settings:
log_level="CRITICAL",
host="127.0.0.1",
port=8780,
encryption_key=None,
)
@@ -0,0 +1,95 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
statements = (
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
username_key TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""",
"""
CREATE TABLE memberships (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
state TEXT NOT NULL DEFAULT 'inactive'
CHECK (state IN ('inactive', 'active', 'disabled')),
expires_at TEXT,
is_permanent INTEGER NOT NULL DEFAULT 0 CHECK (is_permanent IN (0, 1)),
daily_llm_limit INTEGER NOT NULL DEFAULT 50
CHECK (daily_llm_limit BETWEEN 1 AND 1000),
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
)
""",
"""
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
)
""",
"CREATE INDEX sessions_user_id_idx ON sessions(user_id)",
"CREATE INDEX sessions_expires_at_idx ON sessions(expires_at)",
"""
CREATE TABLE birth_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
encrypted_payload TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""",
"""
CREATE TABLE llm_usage_daily (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
usage_date TEXT NOT NULL,
successful_calls INTEGER NOT NULL DEFAULT 0 CHECK (successful_calls >= 0),
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, usage_date)
)
""",
"""
CREATE TABLE system_credentials (
name TEXT PRIMARY KEY,
encrypted_value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT
)
""",
)
for statement in statements:
connection.execute(statement)
def downgrade(connection: sqlite3.Connection) -> None:
for table in (
"system_credentials",
"llm_usage_daily",
"birth_profiles",
"sessions",
"memberships",
"users",
):
connection.execute(f"DROP TABLE {table}")
MIGRATION = Migration(
version=1,
name="create_accounts",
signature="accounts:v1:users-memberships-sessions-profiles-usage-credentials",
upgrade=upgrade,
downgrade=downgrade,
)
@@ -0,0 +1,54 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE llm_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
display_name TEXT NOT NULL,
display_name_key TEXT NOT NULL UNIQUE,
base_url TEXT NOT NULL,
model_identifier TEXT NOT NULL,
encrypted_api_key TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT
)
"""
)
connection.execute(
"""
CREATE TABLE llm_configuration (
id INTEGER PRIMARY KEY CHECK (id = 1),
primary_model_id INTEGER REFERENCES llm_models(id) ON DELETE RESTRICT,
fallback_model_id INTEGER REFERENCES llm_models(id) ON DELETE RESTRICT,
updated_at TEXT,
updated_by INTEGER REFERENCES users(id) ON DELETE RESTRICT,
CHECK (
fallback_model_id IS NULL
OR primary_model_id IS NULL
OR fallback_model_id <> primary_model_id
)
)
"""
)
connection.execute("INSERT INTO llm_configuration (id) VALUES (1)")
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE llm_configuration")
connection.execute("DROP TABLE llm_models")
MIGRATION = Migration(
version=2,
name="create_model_pool",
signature="model-pool:v1:models-primary-fallback-encrypted-key",
upgrade=upgrade,
downgrade=downgrade,
)
+3 -1
View File
@@ -1,3 +1,5 @@
from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS
from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = ()
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL)
+7
View File
@@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass(slots=True)
class BusinessError(Exception):
code: str
message: str
@@ -0,0 +1,3 @@
from backend.features.accounts.service import AccountService, MembershipService
__all__ = ["AccountService", "MembershipService"]
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Request
from backend.features.accounts.models import Principal
from backend.http.errors import AppError
SESSION_COOKIE = "xiaobai_session"
CSRF_COOKIE = "xiaobai_csrf"
def require_principal(request: Request) -> Principal:
service = request.app.state.container.accounts
principal = service.authenticate(request.cookies.get(SESSION_COOKIE))
if principal is None:
raise AppError("authentication_required", "请先登录。", 401)
return principal
AuthenticatedPrincipal = Annotated[Principal, Depends(require_principal)]
def require_csrf(
request: Request,
principal: AuthenticatedPrincipal,
) -> Principal:
valid = request.app.state.container.accounts.verify_csrf(
principal,
request.headers.get("X-CSRF-Token"),
request.cookies.get(CSRF_COOKIE),
)
if not valid:
raise AppError("csrf_failed", "页面状态已过期,请刷新后重试。", 403)
return principal
CsrfPrincipal = Annotated[Principal, Depends(require_csrf)]
def require_admin(principal: AuthenticatedPrincipal) -> Principal:
if not principal.user.is_admin:
raise AppError("access_denied", "当前账号无权执行此操作。", 403)
return principal
def require_admin_write(principal: CsrfPrincipal) -> Principal:
if not principal.user.is_admin:
raise AppError("access_denied", "当前账号无权执行此操作。", 403)
return principal
AdminPrincipal = Annotated[Principal, Depends(require_admin)]
AdminWritePrincipal = Annotated[Principal, Depends(require_admin_write)]
def require_smart_access(
request: Request,
principal: AuthenticatedPrincipal,
) -> Principal:
if not request.app.state.container.memberships.can_use_smart_features(principal):
raise AppError("membership_required", "该功能仅对会员开放。", 403)
return principal
SmartAccessPrincipal = Annotated[Principal, Depends(require_smart_access)]
@@ -0,0 +1,57 @@
from __future__ import annotations
from typing import Literal
from backend.database.connection import Database
from backend.errors import BusinessError
from backend.features.accounts.models import Principal
from backend.features.accounts.repository import SystemCredentialRepository
from backend.features.accounts.service import now_utc
from backend.security import SecretCipher
CredentialName = Literal["tushare_token", "ifind_refresh_token", "ifind_access_token"]
ALLOWED_CREDENTIALS: tuple[CredentialName, ...] = (
"tushare_token",
"ifind_refresh_token",
"ifind_access_token",
)
class SystemCredentialService:
def __init__(
self,
database: Database,
repository: SystemCredentialRepository,
cipher: SecretCipher,
) -> None:
self._database = database
self._repository = repository
self._cipher = cipher
def list_status(self) -> tuple[dict[str, str | bool | None], ...]:
with self._database.read() as connection:
configured = self._repository.list_status(connection)
return tuple(
{
"name": name,
"configured": name in configured,
"updated_at": configured.get(name),
}
for name in ALLOWED_CREDENTIALS
)
def save(self, actor: Principal, name: str, value: str) -> None:
if name not in ALLOWED_CREDENTIALS:
raise BusinessError("invalid_credential", "系统凭据类型无效。")
cleaned = value.strip()
if not cleaned or len(cleaned) > 4096:
raise BusinessError("invalid_credential", "系统凭据内容无效。")
with self._database.transaction() as connection:
self._repository.save(
connection, name, self._cipher.encrypt(cleaned), actor.user.id, now_utc()
)
def get(self, name: CredentialName) -> str | None:
with self._database.read() as connection:
encrypted = self._repository.get_encrypted(connection, name)
return self._cipher.decrypt(encrypted) if encrypted else None
@@ -0,0 +1,336 @@
from __future__ import annotations
import sqlite3
import unicodedata
from datetime import datetime
from urllib.parse import urlsplit
from backend.database.connection import Database
from backend.errors import BusinessError
from backend.features.accounts.models import (
ModelPoolItem,
ModelPoolRecord,
ModelRuntimeConfig,
Principal,
)
from backend.features.accounts.service import now_utc
from backend.security import SecretCipher
def _record(row: sqlite3.Row) -> ModelPoolRecord:
return ModelPoolRecord(
id=int(row["id"]),
display_name=str(row["display_name"]),
display_name_key=str(row["display_name_key"]),
base_url=str(row["base_url"]),
model_identifier=str(row["model_identifier"]),
encrypted_api_key=str(row["encrypted_api_key"]),
created_at=datetime.fromisoformat(str(row["created_at"])),
updated_at=datetime.fromisoformat(str(row["updated_at"])),
updated_by=int(row["updated_by"]),
)
class ModelPoolRepository:
@staticmethod
def count(connection: sqlite3.Connection) -> int:
return int(connection.execute("SELECT COUNT(*) FROM llm_models").fetchone()[0])
@staticmethod
def create(
connection: sqlite3.Connection,
display_name: str,
display_name_key: str,
base_url: str,
model_identifier: str,
encrypted_api_key: str,
actor_id: int,
now: datetime,
) -> ModelPoolRecord:
timestamp = now.isoformat(timespec="seconds")
cursor = connection.execute(
"""
INSERT INTO llm_models (
display_name, display_name_key, base_url, model_identifier,
encrypted_api_key, created_at, updated_at, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
display_name,
display_name_key,
base_url,
model_identifier,
encrypted_api_key,
timestamp,
timestamp,
actor_id,
),
)
row = connection.execute(
"SELECT * FROM llm_models WHERE id = ?", (cursor.lastrowid,)
).fetchone()
return _record(row)
@staticmethod
def get(connection: sqlite3.Connection, model_id: int) -> ModelPoolRecord | None:
row = connection.execute(
"SELECT * FROM llm_models WHERE id = ?", (model_id,)
).fetchone()
return _record(row) if row else None
@staticmethod
def list(connection: sqlite3.Connection) -> tuple[ModelPoolRecord, ...]:
return tuple(
_record(row)
for row in connection.execute("SELECT * FROM llm_models ORDER BY id")
)
@staticmethod
def update(
connection: sqlite3.Connection,
model_id: int,
display_name: str,
display_name_key: str,
base_url: str,
model_identifier: str,
encrypted_api_key: str,
actor_id: int,
now: datetime,
) -> ModelPoolRecord | None:
connection.execute(
"""
UPDATE llm_models SET
display_name = ?, display_name_key = ?, base_url = ?, model_identifier = ?,
encrypted_api_key = ?, updated_at = ?, updated_by = ?
WHERE id = ?
""",
(
display_name,
display_name_key,
base_url,
model_identifier,
encrypted_api_key,
now.isoformat(timespec="seconds"),
actor_id,
model_id,
),
)
return ModelPoolRepository.get(connection, model_id)
@staticmethod
def delete(connection: sqlite3.Connection, model_id: int) -> None:
connection.execute("DELETE FROM llm_models WHERE id = ?", (model_id,))
@staticmethod
def selection(connection: sqlite3.Connection) -> tuple[int | None, int | None]:
row = connection.execute(
"SELECT primary_model_id, fallback_model_id FROM llm_configuration WHERE id = 1"
).fetchone()
return row["primary_model_id"], row["fallback_model_id"]
@staticmethod
def save_selection(
connection: sqlite3.Connection,
primary_id: int,
fallback_id: int | None,
actor_id: int,
now: datetime,
) -> None:
connection.execute(
"""
UPDATE llm_configuration SET
primary_model_id = ?, fallback_model_id = ?, updated_at = ?, updated_by = ?
WHERE id = 1
""",
(
primary_id,
fallback_id,
now.isoformat(timespec="seconds"),
actor_id,
),
)
class ModelPoolService:
MAX_MODELS = 20
def __init__(
self,
database: Database,
repository: ModelPoolRepository,
cipher: SecretCipher,
) -> None:
self._database = database
self._repository = repository
self._cipher = cipher
def list(self) -> tuple[ModelPoolItem, ...]:
with self._database.read() as connection:
records = self._repository.list(connection)
primary_id, fallback_id = self._repository.selection(connection)
return tuple(
self._to_item(record, primary_id, fallback_id)
for record in records
)
def get_item(self, model_id: int) -> ModelPoolItem:
with self._database.read() as connection:
record = self._repository.get(connection, model_id)
primary_id, fallback_id = self._repository.selection(connection)
if record is None:
raise BusinessError("model_not_found", "模型不存在。")
return self._to_item(record, primary_id, fallback_id)
def create(
self,
actor: Principal,
display_name: str,
base_url: str,
model_identifier: str,
api_key: str,
) -> ModelPoolItem:
name, key, url, identifier = self._validate(
display_name, base_url, model_identifier
)
cleaned_key = self._validate_api_key(api_key)
now = now_utc()
try:
with self._database.transaction() as connection:
if self._repository.count(connection) >= self.MAX_MODELS:
raise BusinessError("model_pool_full", "模型池最多可添加20个模型。")
record = self._repository.create(
connection,
name,
key,
url,
identifier,
self._cipher.encrypt(cleaned_key),
actor.user.id,
now,
)
primary_id, _ = self._repository.selection(connection)
if primary_id is None:
self._repository.save_selection(
connection, record.id, None, actor.user.id, now
)
return self.get_item(record.id)
except sqlite3.IntegrityError as exc:
raise BusinessError("model_name_taken", "模型显示名称已存在。") from exc
def update(
self,
actor: Principal,
model_id: int,
display_name: str,
base_url: str,
model_identifier: str,
api_key: str | None,
) -> ModelPoolItem:
name, key, url, identifier = self._validate(
display_name, base_url, model_identifier
)
now = now_utc()
try:
with self._database.transaction() as connection:
current = self._repository.get(connection, model_id)
if current is None:
raise BusinessError("model_not_found", "模型不存在。")
encrypted_key = (
self._cipher.encrypt(self._validate_api_key(api_key))
if api_key is not None
else current.encrypted_api_key
)
updated = self._repository.update(
connection,
model_id,
name,
key,
url,
identifier,
encrypted_key,
actor.user.id,
now,
)
if updated is None:
raise BusinessError("model_not_found", "模型不存在。")
return self.get_item(updated.id)
except sqlite3.IntegrityError as exc:
raise BusinessError("model_name_taken", "模型显示名称已存在。") from exc
def delete(self, model_id: int) -> None:
with self._database.transaction() as connection:
current = self._repository.get(connection, model_id)
if current is None:
raise BusinessError("model_not_found", "模型不存在。")
primary_id, fallback_id = self._repository.selection(connection)
if model_id in {primary_id, fallback_id}:
raise BusinessError("model_in_use", "请先调整主模型或辅助模型后再删除。")
self._repository.delete(connection, model_id)
def select(
self, actor: Principal, primary_id: int, fallback_id: int | None
) -> None:
if fallback_id == primary_id:
raise BusinessError("duplicate_model_role", "主模型和辅助模型不能相同。")
with self._database.transaction() as connection:
if self._repository.get(connection, primary_id) is None:
raise BusinessError("model_not_found", "主模型不存在。")
if fallback_id is not None and self._repository.get(connection, fallback_id) is None:
raise BusinessError("model_not_found", "辅助模型不存在。")
self._repository.save_selection(
connection, primary_id, fallback_id, actor.user.id, now_utc()
)
def runtime_config(self) -> ModelRuntimeConfig:
with self._database.read() as connection:
primary_id, fallback_id = self._repository.selection(connection)
primary = self._repository.get(connection, primary_id) if primary_id else None
fallback = self._repository.get(connection, fallback_id) if fallback_id else None
if primary is None:
raise BusinessError("model_not_configured", "智能解读服务尚未配置。")
return ModelRuntimeConfig(primary=primary, fallback=fallback)
def decrypt_api_key(self, record: ModelPoolRecord) -> str:
return self._cipher.decrypt(record.encrypted_api_key)
@staticmethod
def _to_item(
record: ModelPoolRecord,
primary_id: int | None,
fallback_id: int | None,
) -> ModelPoolItem:
return ModelPoolItem(
id=record.id,
display_name=record.display_name,
base_url=record.base_url,
model_identifier=record.model_identifier,
has_api_key=bool(record.encrypted_api_key),
is_primary=record.id == primary_id,
is_fallback=record.id == fallback_id,
updated_at=record.updated_at,
)
@staticmethod
def _validate(
display_name: str, base_url: str, model_identifier: str
) -> tuple[str, str, str, str]:
name = unicodedata.normalize("NFKC", display_name.strip())
if not 1 <= len(name) <= 40:
raise BusinessError("invalid_model", "模型显示名称应为1至40个字符。")
url = base_url.strip().rstrip("/")
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise BusinessError("invalid_model", "模型服务地址无效。")
if parsed.username or parsed.password:
raise BusinessError("invalid_model", "模型服务地址不能包含账号或密码。")
identifier = model_identifier.strip()
if not 1 <= len(identifier) <= 128:
raise BusinessError("invalid_model", "模型标识应为1至128个字符。")
return name, name.casefold(), url, identifier
@staticmethod
def _validate_api_key(api_key: str) -> str:
cleaned = api_key.strip()
if not 1 <= len(cleaned) <= 4096:
raise BusinessError("invalid_model", "模型密钥无效。")
return cleaned
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True, slots=True)
class UserRecord:
id: int
username: str
username_key: str
password_hash: str
is_admin: bool
status: str
created_at: datetime
updated_at: datetime
@dataclass(frozen=True, slots=True)
class MembershipRecord:
user_id: int
state: str
expires_at: datetime | None
is_permanent: bool
daily_llm_limit: int
updated_at: datetime
updated_by: int | None
@dataclass(frozen=True, slots=True)
class SessionRecord:
token_hash: str
csrf_hash: str
user: UserRecord
membership: MembershipRecord
expires_at: datetime
@dataclass(frozen=True, slots=True)
class Principal:
token_hash: str
csrf_hash: str
user: UserRecord
membership: MembershipRecord
@dataclass(frozen=True, slots=True)
class SessionIssue:
token: str
csrf_token: str
principal: Principal
@dataclass(frozen=True, slots=True)
class MembershipView:
status: str
active: bool
is_permanent: bool
expires_at: datetime | None
remaining_days: int | None
daily_limit: int
used_today: int
remaining_today: int | None
quota_exempt: bool
@dataclass(frozen=True, slots=True)
class BirthProfile:
birth_date: str
birth_time: str
gender: str
updated_at: datetime
@dataclass(frozen=True, slots=True)
class MembershipAccount:
user: UserRecord
membership: MembershipRecord
@dataclass(frozen=True, slots=True)
class MembershipAccountView:
user: UserRecord
membership: MembershipView
@dataclass(frozen=True, slots=True)
class ModelPoolRecord:
id: int
display_name: str
display_name_key: str
base_url: str
model_identifier: str
encrypted_api_key: str
created_at: datetime
updated_at: datetime
updated_by: int
@dataclass(frozen=True, slots=True)
class ModelPoolItem:
id: int
display_name: str
base_url: str
model_identifier: str
has_api_key: bool
is_primary: bool
is_fallback: bool
updated_at: datetime
@dataclass(frozen=True, slots=True)
class ModelRuntimeConfig:
primary: ModelPoolRecord
fallback: ModelPoolRecord | None
@@ -0,0 +1,357 @@
from __future__ import annotations
import sqlite3
from datetime import datetime
from backend.features.accounts.models import (
MembershipAccount,
MembershipRecord,
SessionRecord,
UserRecord,
)
def _datetime(value: str) -> datetime:
return datetime.fromisoformat(value)
def _optional_datetime(value: str | None) -> datetime | None:
return _datetime(value) if value else None
def _user(row: sqlite3.Row) -> UserRecord:
return UserRecord(
id=int(row["id"]),
username=str(row["username"]),
username_key=str(row["username_key"]),
password_hash=str(row["password_hash"]),
is_admin=bool(row["is_admin"]),
status=str(row["status"]),
created_at=_datetime(str(row["created_at"])),
updated_at=_datetime(str(row["updated_at"])),
)
def _membership(row: sqlite3.Row, prefix: str = "") -> MembershipRecord:
return MembershipRecord(
user_id=int(row[f"{prefix}user_id"]),
state=str(row[f"{prefix}state"]),
expires_at=_optional_datetime(row[f"{prefix}expires_at"]),
is_permanent=bool(row[f"{prefix}is_permanent"]),
daily_llm_limit=int(row[f"{prefix}daily_llm_limit"]),
updated_at=_datetime(str(row[f"{prefix}updated_at"])),
updated_by=(
int(row[f"{prefix}updated_by"]) if row[f"{prefix}updated_by"] is not None else None
),
)
class AccountRepository:
@staticmethod
def count_users(connection: sqlite3.Connection) -> int:
return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0])
@staticmethod
def create_user(
connection: sqlite3.Connection,
username: str,
username_key: str,
password_hash: str,
is_admin: bool,
now: datetime,
) -> UserRecord:
timestamp = now.isoformat(timespec="seconds")
cursor = connection.execute(
"""
INSERT INTO users
(username, username_key, password_hash, is_admin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(username, username_key, password_hash, int(is_admin), timestamp, timestamp),
)
return UserRecord(
id=int(cursor.lastrowid),
username=username,
username_key=username_key,
password_hash=password_hash,
is_admin=is_admin,
status="active",
created_at=now,
updated_at=now,
)
@staticmethod
def get_user_by_username_key(
connection: sqlite3.Connection, username_key: str
) -> UserRecord | None:
row = connection.execute(
"SELECT * FROM users WHERE username_key = ?", (username_key,)
).fetchone()
return _user(row) if row else None
@staticmethod
def get_user_by_id(connection: sqlite3.Connection, user_id: int) -> UserRecord | None:
row = connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return _user(row) if row else None
@staticmethod
def create_default_membership(
connection: sqlite3.Connection, user_id: int, now: datetime
) -> MembershipRecord:
timestamp = now.isoformat(timespec="seconds")
connection.execute(
"""
INSERT INTO memberships (user_id, state, daily_llm_limit, updated_at)
VALUES (?, 'inactive', 50, ?)
""",
(user_id, timestamp),
)
return MembershipRecord(user_id, "inactive", None, False, 50, now, None)
@staticmethod
def get_membership(connection: sqlite3.Connection, user_id: int) -> MembershipRecord | None:
row = connection.execute(
"SELECT * FROM memberships WHERE user_id = ?", (user_id,)
).fetchone()
return _membership(row) if row else None
@staticmethod
def update_password(
connection: sqlite3.Connection, user_id: int, password_hash: str, now: datetime
) -> None:
connection.execute(
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
(password_hash, now.isoformat(timespec="seconds"), user_id),
)
@staticmethod
def create_session(
connection: sqlite3.Connection,
token_hash: str,
csrf_hash: str,
user_id: int,
now: datetime,
expires_at: datetime,
) -> None:
timestamp = now.isoformat(timespec="seconds")
connection.execute(
"""
INSERT INTO sessions
(token_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
token_hash,
user_id,
csrf_hash,
timestamp,
expires_at.isoformat(timespec="seconds"),
timestamp,
),
)
@staticmethod
def get_session(connection: sqlite3.Connection, token_hash: str) -> SessionRecord | None:
row = connection.execute(
"""
SELECT
u.*,
m.user_id AS membership_user_id,
m.state AS membership_state,
m.expires_at AS membership_expires_at,
m.is_permanent AS membership_is_permanent,
m.daily_llm_limit AS membership_daily_llm_limit,
m.updated_at AS membership_updated_at,
m.updated_by AS membership_updated_by,
s.token_hash AS session_token_hash,
s.csrf_hash AS session_csrf_hash,
s.expires_at AS session_expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
JOIN memberships m ON m.user_id = u.id
WHERE s.token_hash = ?
""",
(token_hash,),
).fetchone()
if row is None:
return None
return SessionRecord(
token_hash=str(row["session_token_hash"]),
csrf_hash=str(row["session_csrf_hash"]),
user=_user(row),
membership=_membership(row, "membership_"),
expires_at=_datetime(str(row["session_expires_at"])),
)
@staticmethod
def delete_session(connection: sqlite3.Connection, token_hash: str) -> None:
connection.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
@staticmethod
def delete_other_sessions(
connection: sqlite3.Connection, user_id: int, token_hash: str
) -> None:
connection.execute(
"DELETE FROM sessions WHERE user_id = ? AND token_hash <> ?",
(user_id, token_hash),
)
@staticmethod
def get_encrypted_profile(connection: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
return connection.execute(
"SELECT encrypted_payload, updated_at FROM birth_profiles WHERE user_id = ?",
(user_id,),
).fetchone()
@staticmethod
def save_encrypted_profile(
connection: sqlite3.Connection,
user_id: int,
encrypted_payload: str,
now: datetime,
) -> None:
timestamp = now.isoformat(timespec="seconds")
connection.execute(
"""
INSERT INTO birth_profiles (user_id, encrypted_payload, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
encrypted_payload = excluded.encrypted_payload,
updated_at = excluded.updated_at
""",
(user_id, encrypted_payload, timestamp, timestamp),
)
@staticmethod
def delete_profile(connection: sqlite3.Connection, user_id: int) -> None:
connection.execute("DELETE FROM birth_profiles WHERE user_id = ?", (user_id,))
@staticmethod
def usage_today(connection: sqlite3.Connection, user_id: int, usage_date: str) -> int:
row = connection.execute(
"""
SELECT successful_calls FROM llm_usage_daily
WHERE user_id = ? AND usage_date = ?
""",
(user_id, usage_date),
).fetchone()
return int(row["successful_calls"]) if row else 0
@staticmethod
def usage_for_users(connection: sqlite3.Connection, usage_date: str) -> dict[int, int]:
return {
int(row["user_id"]): int(row["successful_calls"])
for row in connection.execute(
"SELECT user_id, successful_calls FROM llm_usage_daily WHERE usage_date = ?",
(usage_date,),
)
}
@staticmethod
def list_memberships(connection: sqlite3.Connection) -> tuple[MembershipAccount, ...]:
rows = connection.execute(
"""
SELECT
u.*,
m.user_id AS membership_user_id,
m.state AS membership_state,
m.expires_at AS membership_expires_at,
m.is_permanent AS membership_is_permanent,
m.daily_llm_limit AS membership_daily_llm_limit,
m.updated_at AS membership_updated_at,
m.updated_by AS membership_updated_by
FROM users u
JOIN memberships m ON m.user_id = u.id
ORDER BY u.id
"""
).fetchall()
return tuple(MembershipAccount(_user(row), _membership(row, "membership_")) for row in rows)
@staticmethod
def get_membership_account(
connection: sqlite3.Connection, user_id: int
) -> MembershipAccount | None:
row = connection.execute(
"""
SELECT
u.*,
m.user_id AS membership_user_id,
m.state AS membership_state,
m.expires_at AS membership_expires_at,
m.is_permanent AS membership_is_permanent,
m.daily_llm_limit AS membership_daily_llm_limit,
m.updated_at AS membership_updated_at,
m.updated_by AS membership_updated_by
FROM users u
JOIN memberships m ON m.user_id = u.id
WHERE u.id = ?
""",
(user_id,),
).fetchone()
return MembershipAccount(_user(row), _membership(row, "membership_")) if row else None
@staticmethod
def update_membership(
connection: sqlite3.Connection,
user_id: int,
state: str,
expires_at: datetime | None,
is_permanent: bool,
daily_limit: int,
updated_by: int,
now: datetime,
) -> MembershipRecord | None:
connection.execute(
"""
UPDATE memberships SET
state = ?, expires_at = ?, is_permanent = ?, daily_llm_limit = ?,
updated_at = ?, updated_by = ?
WHERE user_id = ?
""",
(
state,
expires_at.isoformat(timespec="seconds") if expires_at else None,
int(is_permanent),
daily_limit,
now.isoformat(timespec="seconds"),
updated_by,
user_id,
),
)
return AccountRepository.get_membership(connection, user_id)
class SystemCredentialRepository:
@staticmethod
def list_status(connection: sqlite3.Connection) -> dict[str, str]:
return {
str(row["name"]): str(row["updated_at"])
for row in connection.execute("SELECT name, updated_at FROM system_credentials")
}
@staticmethod
def save(
connection: sqlite3.Connection,
name: str,
encrypted_value: str,
updated_by: int,
now: datetime,
) -> None:
connection.execute(
"""
INSERT INTO system_credentials (name, encrypted_value, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
encrypted_value = excluded.encrypted_value,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(name, encrypted_value, now.isoformat(timespec="seconds"), updated_by),
)
@staticmethod
def get_encrypted(connection: sqlite3.Connection, name: str) -> str | None:
row = connection.execute(
"SELECT encrypted_value FROM system_credentials WHERE name = ?", (name,)
).fetchone()
return str(row["encrypted_value"]) if row else None
+314
View File
@@ -0,0 +1,314 @@
from __future__ import annotations
from datetime import date, time
from typing import Annotated
from fastapi import APIRouter, Path, Request, Response
from backend.features.accounts.auth import (
CSRF_COOKIE,
SESSION_COOKIE,
AdminPrincipal,
AdminWritePrincipal,
AuthenticatedPrincipal,
CsrfPrincipal,
)
from backend.features.accounts.models import (
BirthProfile,
MembershipAccountView,
MembershipView,
Principal,
SessionIssue,
)
from backend.features.accounts.schemas import (
AccountIdentityResponse,
AuthResponse,
BirthProfileInput,
BirthProfileResponse,
CredentialInput,
CredentialsInput,
CredentialStatusResponse,
MembershipAdminResponse,
MembershipStatusResponse,
MembershipUpdateInput,
MessageResponse,
ModelInput,
ModelPoolItemResponse,
ModelSelectionInput,
ModelUpdateInput,
PasswordChangeInput,
)
router = APIRouter()
def _membership_response(view: MembershipView, smart_access: bool) -> MembershipStatusResponse:
return MembershipStatusResponse(
status=view.status,
active=view.active,
is_permanent=view.is_permanent,
expires_at=view.expires_at,
remaining_days=view.remaining_days,
daily_limit=view.daily_limit,
used_today=view.used_today,
remaining_today=view.remaining_today,
quota_exempt=view.quota_exempt,
smart_access=smart_access,
)
def _identity(request: Request, principal: Principal) -> AccountIdentityResponse:
memberships = request.app.state.container.memberships
view = memberships.view_for(principal)
badges = (["admin"] if principal.user.is_admin else []) + (["member"] if view.active else [])
return AccountIdentityResponse(
id=principal.user.id,
username=principal.user.username,
is_admin=principal.user.is_admin,
membership_status=view.status,
membership_active=view.active,
smart_access=memberships.can_use_smart_features(principal),
badges=badges,
)
def _set_session_cookies(request: Request, response: Response, issue: SessionIssue) -> None:
secure = request.app.state.settings.environment == "production"
max_age = 30 * 24 * 60 * 60
response.set_cookie(
SESSION_COOKIE,
issue.token,
max_age=max_age,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
response.set_cookie(
CSRF_COOKIE,
issue.csrf_token,
max_age=max_age,
httponly=False,
secure=secure,
samesite="lax",
path="/",
)
def _clear_session_cookies(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def _profile_response(profile: BirthProfile | None) -> BirthProfileResponse:
if profile is None:
return BirthProfileResponse(configured=False)
return BirthProfileResponse(
configured=True,
birth_date=date.fromisoformat(profile.birth_date),
birth_time=time.fromisoformat(profile.birth_time),
gender=profile.gender,
updated_at=profile.updated_at,
)
def _admin_membership_response(
account: MembershipAccountView,
) -> MembershipAdminResponse:
return MembershipAdminResponse(
user_id=account.user.id,
username=account.user.username,
is_admin=account.user.is_admin,
membership=_membership_response(
account.membership,
account.user.is_admin or account.membership.active,
),
)
@router.post("/auth/register", response_model=AuthResponse, status_code=201)
def register(payload: CredentialsInput, request: Request, response: Response) -> AuthResponse:
issue = request.app.state.container.accounts.register(payload.username, payload.password)
_set_session_cookies(request, response, issue)
return AuthResponse(account=_identity(request, issue.principal), csrf_token=issue.csrf_token)
@router.post("/auth/login", response_model=AuthResponse)
def login(payload: CredentialsInput, request: Request, response: Response) -> AuthResponse:
issue = request.app.state.container.accounts.login(payload.username, payload.password)
_set_session_cookies(request, response, issue)
return AuthResponse(account=_identity(request, issue.principal), csrf_token=issue.csrf_token)
@router.get("/auth/session", response_model=AccountIdentityResponse)
def session(request: Request, principal: AuthenticatedPrincipal):
return _identity(request, principal)
@router.post("/auth/logout", response_model=MessageResponse)
@router.post("/auth/switch-account", response_model=MessageResponse)
def logout(
request: Request,
response: Response,
principal: CsrfPrincipal,
) -> MessageResponse:
request.app.state.container.accounts.logout(principal)
_clear_session_cookies(response)
return MessageResponse(message="已退出当前账号。")
@router.patch("/account/password", response_model=MessageResponse)
def change_password(
payload: PasswordChangeInput,
request: Request,
principal: CsrfPrincipal,
) -> MessageResponse:
request.app.state.container.accounts.change_password(
principal,
payload.current_password,
payload.new_password,
payload.confirmation,
)
return MessageResponse(message="密码已修改。")
@router.get("/account/profile", response_model=BirthProfileResponse)
def get_profile(request: Request, principal: AuthenticatedPrincipal) -> BirthProfileResponse:
return _profile_response(request.app.state.container.accounts.get_profile(principal.user.id))
@router.put("/account/profile", response_model=BirthProfileResponse)
def save_profile(
payload: BirthProfileInput,
request: Request,
principal: CsrfPrincipal,
) -> BirthProfileResponse:
profile = request.app.state.container.accounts.save_profile(
principal.user.id,
payload.birth_date,
payload.birth_time.isoformat(timespec="minutes"),
payload.gender,
)
return _profile_response(profile)
@router.delete("/account/profile", response_model=MessageResponse)
def delete_profile(request: Request, principal: CsrfPrincipal) -> MessageResponse:
request.app.state.container.accounts.delete_profile(principal.user.id)
return MessageResponse(message="个人出生资料已删除。")
@router.get("/account/membership", response_model=MembershipStatusResponse)
def get_membership(request: Request, principal: AuthenticatedPrincipal) -> MembershipStatusResponse:
memberships = request.app.state.container.memberships
view = memberships.view_for(principal)
return _membership_response(view, memberships.can_use_smart_features(principal))
@router.get(
"/admin/memberships",
response_model=list[MembershipAdminResponse],
)
def list_memberships(request: Request, _principal: AdminPrincipal) -> list[MembershipAdminResponse]:
return [
_admin_membership_response(account)
for account in request.app.state.container.memberships.list_accounts()
]
@router.patch(
"/admin/memberships/{user_id}",
response_model=MembershipAdminResponse,
)
def update_membership(
payload: MembershipUpdateInput,
request: Request,
user_id: Annotated[int, Path(ge=1)],
principal: AdminWritePrincipal,
) -> MembershipAdminResponse:
request.app.state.container.memberships.update(
principal, user_id, payload.action, payload.duration, payload.daily_limit
)
return _admin_membership_response(request.app.state.container.memberships.get_account(user_id))
@router.get(
"/admin/system/credentials",
response_model=list[CredentialStatusResponse],
)
def list_credentials(
request: Request, _principal: AdminPrincipal
) -> tuple[dict[str, str | bool | None], ...]:
return request.app.state.container.system_credentials.list_status()
@router.put("/admin/system/credentials/{name}", response_model=MessageResponse)
def save_credential(
payload: CredentialInput,
request: Request,
name: Annotated[str, Path(min_length=1, max_length=64)],
principal: AdminWritePrincipal,
) -> MessageResponse:
request.app.state.container.system_credentials.save(principal, name, payload.value)
return MessageResponse(message="系统凭据已保存。")
@router.get("/admin/models", response_model=list[ModelPoolItemResponse])
def list_models(
request: Request, _principal: AdminPrincipal
) -> tuple[object, ...]:
return request.app.state.container.model_pool.list()
@router.post("/admin/models", response_model=ModelPoolItemResponse, status_code=201)
def create_model(
payload: ModelInput,
request: Request,
principal: AdminWritePrincipal,
) -> ModelPoolItemResponse:
return request.app.state.container.model_pool.create(
principal,
payload.display_name,
payload.base_url,
payload.model_identifier,
payload.api_key,
)
@router.put("/admin/models/selection", response_model=MessageResponse)
def select_models(
payload: ModelSelectionInput,
request: Request,
principal: AdminWritePrincipal,
) -> MessageResponse:
request.app.state.container.model_pool.select(
principal, payload.primary_model_id, payload.fallback_model_id
)
return MessageResponse(message="主模型和辅助模型已更新。")
@router.put("/admin/models/{model_id}", response_model=ModelPoolItemResponse)
def update_model(
payload: ModelUpdateInput,
request: Request,
model_id: Annotated[int, Path(ge=1)],
principal: AdminWritePrincipal,
) -> ModelPoolItemResponse:
return request.app.state.container.model_pool.update(
principal,
model_id,
payload.display_name,
payload.base_url,
payload.model_identifier,
payload.api_key,
)
@router.delete("/admin/models/{model_id}", response_model=MessageResponse)
def delete_model(
request: Request,
model_id: Annotated[int, Path(ge=1)],
_principal: AdminWritePrincipal,
) -> MessageResponse:
request.app.state.container.model_pool.delete(model_id)
return MessageResponse(message="模型已删除。")
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
from datetime import date, datetime, time
from typing import Literal
from pydantic import BaseModel, Field
from backend.features.accounts.service import PROFILE_PRIVACY_NOTICE
class CredentialsInput(BaseModel):
username: str
password: str
class PasswordChangeInput(BaseModel):
current_password: str
new_password: str
confirmation: str
class AccountIdentityResponse(BaseModel):
id: int
username: str
is_admin: bool
membership_status: str
membership_active: bool
smart_access: bool
badges: list[str]
class AuthResponse(BaseModel):
account: AccountIdentityResponse
csrf_token: str
class MessageResponse(BaseModel):
message: str
class BirthProfileInput(BaseModel):
birth_date: date
birth_time: time
gender: Literal["male", "female"]
class BirthProfileResponse(BaseModel):
configured: bool
birth_date: date | None = None
birth_time: time | None = None
gender: str | None = None
updated_at: datetime | None = None
privacy_notice: str = PROFILE_PRIVACY_NOTICE
class MembershipStatusResponse(BaseModel):
status: str
active: bool
is_permanent: bool
expires_at: datetime | None
remaining_days: int | None
daily_limit: int
used_today: int
remaining_today: int | None
quota_exempt: bool
smart_access: bool
description: str = "开通会员后可使用智能选股、问师、问天、复盘助手等智能功能。"
usage_package_available: bool = False
class MembershipAdminResponse(BaseModel):
user_id: int
username: str
is_admin: bool
membership: MembershipStatusResponse
class MembershipUpdateInput(BaseModel):
action: Literal["activate", "disable", "set_limit"]
duration: Literal["1_month", "3_months", "12_months", "3_years", "permanent"] | None = None
daily_limit: int | None = Field(default=None, ge=1, le=1000)
class CredentialStatusResponse(BaseModel):
name: str
configured: bool
updated_at: datetime | None
class CredentialInput(BaseModel):
value: str = Field(min_length=1, max_length=4096)
class ModelInput(BaseModel):
display_name: str = Field(min_length=1, max_length=40)
base_url: str = Field(min_length=1, max_length=2048)
model_identifier: str = Field(min_length=1, max_length=128)
api_key: str = Field(min_length=1, max_length=4096)
class ModelUpdateInput(BaseModel):
display_name: str = Field(min_length=1, max_length=40)
base_url: str = Field(min_length=1, max_length=2048)
model_identifier: str = Field(min_length=1, max_length=128)
api_key: str | None = Field(default=None, min_length=1, max_length=4096)
class ModelPoolItemResponse(BaseModel):
id: int
display_name: str
base_url: str
model_identifier: str
has_api_key: bool
is_primary: bool
is_fallback: bool
updated_at: datetime
class ModelSelectionInput(BaseModel):
primary_model_id: int = Field(ge=1)
fallback_model_id: int | None = Field(default=None, ge=1)
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
import calendar
import hashlib
import hmac
import json
import re
import secrets
import sqlite3
import unicodedata
from datetime import UTC, date, datetime, timedelta
from math import ceil
from zoneinfo import ZoneInfo
from backend.database.connection import Database
from backend.errors import BusinessError
from backend.features.accounts.models import (
BirthProfile,
MembershipAccountView,
MembershipRecord,
MembershipView,
Principal,
SessionIssue,
UserRecord,
)
from backend.features.accounts.repository import AccountRepository
from backend.security import PasswordHasher, PasswordPolicyError, SecretCipher
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}")
SHANGHAI = ZoneInfo("Asia/Shanghai")
PROFILE_PRIVACY_NOTICE = "原始出生资料加密保存,仅当前账号可见。"
SESSION_LIFETIME = timedelta(days=30)
def now_utc() -> datetime:
return datetime.now(UTC)
def username_key(username: str) -> str:
return unicodedata.normalize("NFKC", username).casefold()
def normalize_username(raw_username: str) -> tuple[str, str]:
username = unicodedata.normalize("NFKC", raw_username.strip())
if not USERNAME_PATTERN.fullmatch(username):
raise BusinessError(
"invalid_username", "账号名应为3至30位中文、字母、数字、下划线或连字符。"
)
return username, username_key(username)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def add_months(value: datetime, months: int) -> datetime:
month_index = value.year * 12 + value.month - 1 + months
year, zero_based_month = divmod(month_index, 12)
month = zero_based_month + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return value.replace(year=year, month=month, day=day)
def membership_active(membership: MembershipRecord, now: datetime) -> bool:
return membership.state == "active" and (
membership.is_permanent
or (membership.expires_at is not None and membership.expires_at > now)
)
def membership_view(
user: UserRecord,
membership: MembershipRecord,
used_today: int,
now: datetime,
) -> MembershipView:
active = membership_active(membership, now)
if membership.state == "disabled":
status = "disabled"
elif active:
status = "active"
else:
status = "not_open"
remaining_days = None
if active and not membership.is_permanent and membership.expires_at is not None:
remaining_days = max(0, ceil((membership.expires_at - now).total_seconds() / 86400))
quota_exempt = user.is_admin
return MembershipView(
status=status,
active=active,
is_permanent=membership.is_permanent,
expires_at=membership.expires_at if not membership.is_permanent else None,
remaining_days=remaining_days,
daily_limit=membership.daily_llm_limit,
used_today=used_today,
remaining_today=(None if quota_exempt else max(0, membership.daily_llm_limit - used_today)),
quota_exempt=quota_exempt,
)
class AccountService:
def __init__(
self,
database: Database,
repository: AccountRepository,
password_hasher: PasswordHasher,
cipher: SecretCipher,
) -> None:
self._database = database
self._repository = repository
self._password_hasher = password_hasher
self._cipher = cipher
self._dummy_password_hash = password_hasher.hash("not-a-real-account-123!")
def register(self, raw_username: str, password: str) -> SessionIssue:
username, normalized_key = normalize_username(raw_username)
try:
password_hash = self._password_hasher.hash(password)
except PasswordPolicyError as exc:
raise BusinessError("invalid_password", str(exc)) from exc
now = now_utc()
try:
with self._database.transaction() as connection:
is_admin = self._repository.count_users(connection) == 0
user = self._repository.create_user(
connection, username, normalized_key, password_hash, is_admin, now
)
membership = self._repository.create_default_membership(connection, user.id, now)
return self._issue_session(connection, user, membership, now)
except sqlite3.IntegrityError as exc:
raise BusinessError("username_taken", "该账号名已被使用。") from exc
def login(self, raw_username: str, password: str) -> SessionIssue:
normalized_key = username_key(raw_username.strip())
now = now_utc()
with self._database.transaction() as connection:
user = self._repository.get_user_by_username_key(connection, normalized_key)
encoded_hash = user.password_hash if user else self._dummy_password_hash
password_valid = self._password_hasher.verify(password, encoded_hash)
if user is None or not password_valid or user.status != "active":
raise BusinessError("invalid_credentials", "账号或密码错误。")
membership = self._repository.get_membership(connection, user.id)
if membership is None:
raise BusinessError("account_unavailable", "账号暂不可用,请联系管理员。")
return self._issue_session(connection, user, membership, now)
def authenticate(self, raw_token: str | None) -> Principal | None:
if not raw_token:
return None
token_hash = hash_token(raw_token)
now = now_utc()
with self._database.read() as connection:
session = self._repository.get_session(connection, token_hash)
if session is None:
return None
if session.expires_at <= now or session.user.status != "active":
with self._database.transaction() as connection:
self._repository.delete_session(connection, token_hash)
return None
return Principal(
token_hash=session.token_hash,
csrf_hash=session.csrf_hash,
user=session.user,
membership=session.membership,
)
@staticmethod
def verify_csrf(
principal: Principal,
header_token: str | None,
cookie_token: str | None,
) -> bool:
if not header_token or not cookie_token:
return False
if not hmac.compare_digest(header_token, cookie_token):
return False
return hmac.compare_digest(hash_token(header_token), principal.csrf_hash)
def logout(self, principal: Principal) -> None:
with self._database.transaction() as connection:
self._repository.delete_session(connection, principal.token_hash)
def change_password(
self,
principal: Principal,
current_password: str,
new_password: str,
confirmation: str,
) -> None:
if new_password != confirmation:
raise BusinessError("password_mismatch", "两次输入的新密码不一致。")
if not self._password_hasher.verify(current_password, principal.user.password_hash):
raise BusinessError("invalid_current_password", "当前密码不正确。")
try:
new_hash = self._password_hasher.hash(new_password)
except PasswordPolicyError as exc:
raise BusinessError("invalid_password", str(exc)) from exc
now = now_utc()
with self._database.transaction() as connection:
self._repository.update_password(connection, principal.user.id, new_hash, now)
self._repository.delete_other_sessions(
connection, principal.user.id, principal.token_hash
)
def get_profile(self, user_id: int) -> BirthProfile | None:
with self._database.read() as connection:
row = self._repository.get_encrypted_profile(connection, user_id)
if row is None:
return None
try:
payload = json.loads(self._cipher.decrypt(str(row["encrypted_payload"])))
except (ValueError, json.JSONDecodeError, KeyError) as exc:
raise BusinessError(
"profile_unavailable", "个人资料暂时无法读取,请联系管理员。"
) from exc
return BirthProfile(
birth_date=str(payload["birth_date"]),
birth_time=str(payload["birth_time"]),
gender=str(payload["gender"]),
updated_at=datetime.fromisoformat(str(row["updated_at"])),
)
def save_profile(
self, user_id: int, birth_date: date, birth_time: str, gender: str
) -> BirthProfile:
today = datetime.now(SHANGHAI).date()
if birth_date > today:
raise BusinessError("invalid_birth_date", "出生日期不能晚于今天。")
payload = json.dumps(
{
"birth_date": birth_date.isoformat(),
"birth_time": birth_time,
"gender": gender,
},
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
now = now_utc()
with self._database.transaction() as connection:
self._repository.save_encrypted_profile(
connection, user_id, self._cipher.encrypt(payload), now
)
return BirthProfile(birth_date.isoformat(), birth_time, gender, now)
def delete_profile(self, user_id: int) -> None:
with self._database.transaction() as connection:
self._repository.delete_profile(connection, user_id)
def _issue_session(
self,
connection: sqlite3.Connection,
user: UserRecord,
membership: MembershipRecord,
now: datetime,
) -> SessionIssue:
token = secrets.token_urlsafe(32)
csrf_token = secrets.token_urlsafe(32)
token_hash = hash_token(token)
csrf_hash = hash_token(csrf_token)
self._repository.create_session(
connection,
token_hash,
csrf_hash,
user.id,
now,
now + SESSION_LIFETIME,
)
return SessionIssue(
token=token,
csrf_token=csrf_token,
principal=Principal(token_hash, csrf_hash, user, membership),
)
class MembershipService:
DURATION_MONTHS = {
"1_month": 1,
"3_months": 3,
"12_months": 12,
"3_years": 36,
}
def __init__(self, database: Database, repository: AccountRepository) -> None:
self._database = database
self._repository = repository
def view_for(self, principal: Principal) -> MembershipView:
today = datetime.now(SHANGHAI).date().isoformat()
with self._database.read() as connection:
used_today = self._repository.usage_today(connection, principal.user.id, today)
return membership_view(principal.user, principal.membership, used_today, now_utc())
def can_use_smart_features(self, principal: Principal) -> bool:
return principal.user.is_admin or membership_active(principal.membership, now_utc())
def list_accounts(self) -> tuple[MembershipAccountView, ...]:
today = datetime.now(SHANGHAI).date().isoformat()
now = now_utc()
with self._database.read() as connection:
accounts = self._repository.list_memberships(connection)
usage = self._repository.usage_for_users(connection, today)
return tuple(
MembershipAccountView(
account.user,
membership_view(
account.user,
account.membership,
usage.get(account.user.id, 0),
now,
),
)
for account in accounts
)
def get_account(self, user_id: int) -> MembershipAccountView:
today = datetime.now(SHANGHAI).date().isoformat()
with self._database.read() as connection:
account = self._repository.get_membership_account(connection, user_id)
used_today = self._repository.usage_today(connection, user_id, today)
if account is None:
raise BusinessError("account_not_found", "账号不存在。")
return MembershipAccountView(
account.user,
membership_view(account.user, account.membership, used_today, now_utc()),
)
def update(
self,
actor: Principal,
user_id: int,
action: str,
duration: str | None,
daily_limit: int | None,
) -> MembershipRecord:
now = now_utc()
with self._database.transaction() as connection:
current = self._repository.get_membership(connection, user_id)
if current is None:
raise BusinessError("account_not_found", "账号不存在。")
limit = daily_limit if daily_limit is not None else current.daily_llm_limit
if not 1 <= limit <= 1000:
raise BusinessError("invalid_daily_limit", "每日智能分析上限应为1至1000次。")
if action == "disable":
state = "disabled"
expires_at = current.expires_at
permanent = current.is_permanent
elif action == "activate":
if duration == "permanent":
state, expires_at, permanent = "active", None, True
elif duration in self.DURATION_MONTHS:
if current.state == "active" and current.is_permanent:
raise BusinessError("permanent_membership", "永久会员无需续期。")
base = (
current.expires_at
if membership_active(current, now) and current.expires_at is not None
else now
)
state = "active"
expires_at = add_months(base, self.DURATION_MONTHS[duration])
permanent = False
else:
raise BusinessError("invalid_membership_duration", "请选择有效的会员时长。")
elif action == "set_limit":
state = current.state
expires_at = current.expires_at
permanent = current.is_permanent
else:
raise BusinessError("invalid_membership_action", "会员操作无效。")
updated = self._repository.update_membership(
connection,
user_id,
state,
expires_at,
permanent,
limit,
actor.user.id,
now,
)
if updated is None:
raise BusinessError("account_not_found", "账号不存在。")
return updated
+20 -1
View File
@@ -9,10 +9,12 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
from backend.errors import BusinessError
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
@dataclass(slots=True)
class AppError(Exception):
code: str
message: str
@@ -39,6 +41,23 @@ def _response(request: Request, status_code: int, code: str, message: str) -> JS
def install_error_handlers(application: FastAPI) -> None:
@application.exception_handler(BusinessError)
async def business_error(request: Request, error: BusinessError) -> JSONResponse:
status_code = {
"invalid_credentials": HTTPStatus.UNAUTHORIZED,
"account_unavailable": HTTPStatus.FORBIDDEN,
"username_taken": HTTPStatus.CONFLICT,
"account_not_found": HTTPStatus.NOT_FOUND,
"model_not_found": HTTPStatus.NOT_FOUND,
"permanent_membership": HTTPStatus.CONFLICT,
"model_in_use": HTTPStatus.CONFLICT,
"model_name_taken": HTTPStatus.CONFLICT,
"model_pool_full": HTTPStatus.CONFLICT,
"model_not_configured": HTTPStatus.SERVICE_UNAVAILABLE,
"profile_unavailable": HTTPStatus.SERVICE_UNAVAILABLE,
}.get(error.code, HTTPStatus.BAD_REQUEST)
return _response(request, status_code, error.code, error.message)
@application.exception_handler(AppError)
async def application_error(request: Request, error: AppError) -> JSONResponse:
return _response(request, error.status_code, error.code, error.message)
+2
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter
from backend.features.accounts.routes import router as accounts_router
from backend.http.routes.health import router as health_router
api_router = APIRouter()
api_router.include_router(health_router)
api_router.include_router(accounts_router)
+4
View File
@@ -0,0 +1,4 @@
from backend.security.cipher import SecretCipher, load_or_create_cipher
from backend.security.passwords import PasswordHasher, PasswordPolicyError
__all__ = ["PasswordHasher", "PasswordPolicyError", "SecretCipher", "load_or_create_cipher"]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from cryptography.fernet import Fernet, InvalidToken
from backend.bootstrap.settings import ConfigurationError, Settings
@dataclass(frozen=True, slots=True)
class SecretCipher:
_fernet: Fernet
@classmethod
def from_key(cls, key: str) -> SecretCipher:
try:
return cls(Fernet(key.encode("ascii")))
except (ValueError, UnicodeEncodeError) as exc:
raise ConfigurationError("APP_ENCRYPTION_KEY is not a valid Fernet key") from exc
def encrypt(self, value: str) -> str:
return self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
def decrypt(self, value: str) -> str:
try:
return self._fernet.decrypt(value.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc:
raise ValueError("Encrypted value cannot be decrypted") from exc
def load_or_create_cipher(settings: Settings) -> SecretCipher:
if settings.encryption_key is not None:
return SecretCipher.from_key(settings.encryption_key)
if settings.environment == "production":
raise ConfigurationError("APP_ENCRYPTION_KEY is required in production")
key_file = settings.data_directory / ".encryption.key"
key_file.parent.mkdir(parents=True, exist_ok=True)
if key_file.exists():
return SecretCipher.from_key(key_file.read_text(encoding="ascii").strip())
key = Fernet.generate_key().decode("ascii")
try:
with key_file.open("x", encoding="ascii") as handle:
handle.write(key)
os.chmod(key_file, 0o600)
except FileExistsError:
key = key_file.read_text(encoding="ascii").strip()
return SecretCipher.from_key(key)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
from dataclasses import dataclass
class PasswordPolicyError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class PasswordHasher:
work_factor: int = 2**15
block_size: int = 8
parallelism: int = 1
salt_bytes: int = 16
key_bytes: int = 32
def validate(self, password: str) -> None:
if not 8 <= len(password) <= 128:
raise PasswordPolicyError("密码长度应为8至128位。")
categories = (
any(character.isalpha() for character in password),
any(character.isdigit() for character in password),
any(not character.isalnum() for character in password),
)
if sum(categories) < 2:
raise PasswordPolicyError("密码至少包含字母、数字、符号中的两类。")
def hash(self, password: str) -> str:
self.validate(password)
salt = secrets.token_bytes(self.salt_bytes)
digest = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=self.work_factor,
r=self.block_size,
p=self.parallelism,
dklen=self.key_bytes,
maxmem=64 * 1024 * 1024,
)
encoded_salt = base64.urlsafe_b64encode(salt).decode("ascii")
encoded_digest = base64.urlsafe_b64encode(digest).decode("ascii")
return (
f"scrypt${self.work_factor}${self.block_size}${self.parallelism}"
f"${encoded_salt}${encoded_digest}"
)
def verify(self, password: str, encoded: str) -> bool:
try:
scheme, work_factor, block_size, parallelism, salt, expected = encoded.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode("utf-8"),
salt=base64.urlsafe_b64decode(salt.encode("ascii")),
n=int(work_factor),
r=int(block_size),
p=int(parallelism),
dklen=self.key_bytes,
maxmem=64 * 1024 * 1024,
)
return hmac.compare_digest(base64.urlsafe_b64encode(digest).decode("ascii"), expected)
except (TypeError, ValueError):
return False
+1
View File
@@ -1,3 +1,4 @@
cryptography==49.0.0
fastapi==0.141.0
pydantic==2.13.4
tzdata==2026.3
+1
View File
@@ -0,0 +1 @@
"""Tests for the rebuilt application."""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import httpx
from fastapi import FastAPI
type Scenario[Result] = Callable[[httpx.AsyncClient], Awaitable[Result]]
def run_scenario[Result](application: FastAPI, scenario: Scenario[Result]) -> Result:
async def run() -> Result:
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
return await scenario(client)
return asyncio.run(run())
def request(application: FastAPI, path: str) -> httpx.Response:
async def get(client: httpx.AsyncClient) -> httpx.Response:
return await client.get(path)
return run_scenario(application, get)
+377
View File
@@ -0,0 +1,377 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime
import httpx
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from backend.features.accounts.auth import CSRF_COOKIE, SESSION_COOKIE, SmartAccessPrincipal
from backend.features.accounts.service import add_months
from tests.support import run_scenario
ADMIN_PASSWORD = "Admin-pass-123!"
USER_PASSWORD = "User-pass-123!"
@dataclass(frozen=True)
class BrowserSession:
session: str
csrf: str
def current_session(client: httpx.AsyncClient) -> BrowserSession:
return BrowserSession(
session=client.cookies.get(SESSION_COOKIE),
csrf=client.cookies.get(CSRF_COOKIE),
)
def use_session(client: httpx.AsyncClient, session: BrowserSession) -> None:
client.cookies.clear()
client.cookies.set(SESSION_COOKIE, session.session)
client.cookies.set(CSRF_COOKIE, session.csrf)
def csrf_headers(session: BrowserSession) -> dict[str, str]:
return {"X-CSRF-Token": session.csrf}
async def register(
client: httpx.AsyncClient, username: str, password: str
) -> tuple[httpx.Response, BrowserSession]:
response = await client.post(
"/api/auth/register", json={"username": username, "password": password}
)
return response, current_session(client)
def test_first_account_is_admin_and_second_is_regular_user(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
admin_response, admin_session = await register(client, "leefer", ADMIN_PASSWORD)
assert admin_response.status_code == 201
assert admin_response.json()["account"] == {
"id": 1,
"username": "leefer",
"is_admin": True,
"membership_status": "not_open",
"membership_active": False,
"smart_access": True,
"badges": ["admin"],
}
client.cookies.clear()
user_response, user_session = await register(client, "小白用户", USER_PASSWORD)
assert user_response.status_code == 201
assert user_response.json()["account"]["is_admin"] is False
assert user_response.json()["account"]["smart_access"] is False
assert user_response.json()["account"]["badges"] == []
use_session(client, admin_session)
assert (await client.get("/api/auth/session")).json()["is_admin"] is True
use_session(client, user_session)
assert (await client.get("/api/auth/session")).json()["is_admin"] is False
with sqlite3.connect(application.state.settings.database_path) as connection:
stored = {row[0] for row in connection.execute("SELECT token_hash FROM sessions")}
assert admin_session.session not in stored
assert user_session.session not in stored
assert all(len(value) == 64 for value in stored)
run_scenario(application, scenario)
def test_csrf_password_change_and_other_session_revocation(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, first_session = await register(client, "secure-user", USER_PASSWORD)
second_login = await client.post(
"/api/auth/login",
json={"username": "secure-user", "password": USER_PASSWORD},
)
assert second_login.status_code == 200
second_session = current_session(client)
without_csrf = await client.patch(
"/api/account/password",
json={
"current_password": USER_PASSWORD,
"new_password": "Changed-pass-456!",
"confirmation": "Changed-pass-456!",
},
)
assert without_csrf.status_code == 403
assert without_csrf.json()["error"]["code"] == "csrf_failed"
wrong_current = await client.patch(
"/api/account/password",
headers=csrf_headers(second_session),
json={
"current_password": "Wrong-pass-999!",
"new_password": "Changed-pass-456!",
"confirmation": "Changed-pass-456!",
},
)
assert wrong_current.status_code == 400
assert wrong_current.json()["error"]["code"] == "invalid_current_password"
changed = await client.patch(
"/api/account/password",
headers=csrf_headers(second_session),
json={
"current_password": USER_PASSWORD,
"new_password": "Changed-pass-456!",
"confirmation": "Changed-pass-456!",
},
)
assert changed.status_code == 200
use_session(client, first_session)
assert (await client.get("/api/auth/session")).status_code == 401
client.cookies.clear()
old_login = await client.post(
"/api/auth/login",
json={"username": "secure-user", "password": USER_PASSWORD},
)
assert old_login.status_code == 401
assert old_login.json()["error"]["message"] == "账号或密码错误。"
new_login = await client.post(
"/api/auth/login",
json={"username": "secure-user", "password": "Changed-pass-456!"},
)
assert new_login.status_code == 200
run_scenario(application, scenario)
def test_birth_profile_is_encrypted_and_isolated_by_account(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, first_session = await register(client, "profile-a", USER_PASSWORD)
saved = await client.put(
"/api/account/profile",
headers=csrf_headers(first_session),
json={"birth_date": "1990-03-08", "birth_time": "08:30", "gender": "male"},
)
assert saved.status_code == 200
assert saved.json()["configured"] is True
assert "仅当前账号可见" in saved.json()["privacy_notice"]
client.cookies.clear()
_, second_session = await register(client, "profile-b", USER_PASSWORD)
assert (await client.get("/api/account/profile")).json()["configured"] is False
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted = connection.execute(
"SELECT encrypted_payload FROM birth_profiles WHERE user_id = 1"
).fetchone()[0]
assert "1990-03-08" not in encrypted
assert "08:30" not in encrypted
use_session(client, first_session)
own_profile = await client.get("/api/account/profile")
assert own_profile.json()["birth_date"] == "1990-03-08"
deleted = await client.delete("/api/account/profile", headers=csrf_headers(first_session))
assert deleted.status_code == 200
assert (await client.get("/api/account/profile")).json()["configured"] is False
use_session(client, second_session)
assert (await client.get("/api/account/profile")).json()["configured"] is False
run_scenario(application, scenario)
def test_membership_and_admin_are_independent_dimensions(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "admin-user", ADMIN_PASSWORD)
client.cookies.clear()
user_response, user_session = await register(client, "member-user", USER_PASSWORD)
user_id = user_response.json()["account"]["id"]
use_session(client, admin_session)
activated = await client.patch(
f"/api/admin/memberships/{user_id}",
headers=csrf_headers(admin_session),
json={"action": "activate", "duration": "1_month", "daily_limit": 80},
)
assert activated.status_code == 200
assert activated.json()["membership"]["active"] is True
assert activated.json()["membership"]["daily_limit"] == 80
admin_membership = await client.get("/api/account/membership")
assert admin_membership.json()["active"] is False
assert admin_membership.json()["smart_access"] is True
use_session(client, user_session)
user_identity = await client.get("/api/auth/session")
assert user_identity.json()["badges"] == ["member"]
assert user_identity.json()["smart_access"] is True
use_session(client, admin_session)
permanent = await client.patch(
"/api/admin/memberships/1",
headers=csrf_headers(admin_session),
json={"action": "activate", "duration": "permanent"},
)
assert permanent.status_code == 200
assert permanent.json()["membership"]["is_permanent"] is True
assert permanent.json()["membership"]["expires_at"] is None
assert permanent.json()["membership"]["remaining_days"] is None
identity = await client.get("/api/auth/session")
assert identity.json()["badges"] == ["admin", "member"]
run_scenario(application, scenario)
def test_non_admin_cannot_read_or_write_system_credentials(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "system-admin", ADMIN_PASSWORD)
client.cookies.clear()
_, user_session = await register(client, "system-user", USER_PASSWORD)
denied_read = await client.get("/api/admin/system/credentials")
assert denied_read.status_code == 403
denied_write = await client.put(
"/api/admin/system/credentials/tushare_token",
headers=csrf_headers(user_session),
json={"value": "user-must-not-save-this"},
)
assert denied_write.status_code == 403
use_session(client, admin_session)
saved = await client.put(
"/api/admin/system/credentials/tushare_token",
headers=csrf_headers(admin_session),
json={"value": "real-test-credential-value"},
)
assert saved.status_code == 200
statuses = await client.get("/api/admin/system/credentials")
assert statuses.status_code == 200
configured = next(item for item in statuses.json() if item["name"] == "tushare_token")
assert configured["configured"] is True
assert "value" not in configured
assert "real-test-credential-value" not in statuses.text
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted = connection.execute(
"SELECT encrypted_value FROM system_credentials WHERE name = 'tushare_token'"
).fetchone()[0]
assert encrypted != "real-test-credential-value"
assert "real-test-credential-value" not in encrypted
run_scenario(application, scenario)
def test_session_cookies_duplicate_username_and_logout(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
response, session = await register(client, "CaseUser", USER_PASSWORD)
cookies = response.headers.get_list("set-cookie")
session_cookie = next(value for value in cookies if value.startswith(f"{SESSION_COOKIE}="))
csrf_cookie = next(value for value in cookies if value.startswith(f"{CSRF_COOKIE}="))
assert "HttpOnly" in session_cookie
assert "SameSite=lax" in session_cookie
assert "HttpOnly" not in csrf_cookie
assert "SameSite=lax" in csrf_cookie
client.cookies.clear()
duplicate = await client.post(
"/api/auth/register",
json={"username": "caseuser", "password": USER_PASSWORD},
)
assert duplicate.status_code == 409
assert duplicate.json()["error"]["code"] == "username_taken"
use_session(client, session)
logged_out = await client.post("/api/auth/logout", headers=csrf_headers(session))
assert logged_out.status_code == 200
assert (await client.get("/api/auth/session")).status_code == 401
run_scenario(application, scenario)
def test_active_membership_renews_from_existing_expiry_and_disable_keeps_it(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "renew-admin", ADMIN_PASSWORD)
client.cookies.clear()
user_response, _ = await register(client, "renew-user", USER_PASSWORD)
user_id = user_response.json()["account"]["id"]
use_session(client, admin_session)
first = await client.patch(
f"/api/admin/memberships/{user_id}",
headers=csrf_headers(admin_session),
json={"action": "activate", "duration": "1_month"},
)
first_expiry = datetime.fromisoformat(first.json()["membership"]["expires_at"])
extended = await client.patch(
f"/api/admin/memberships/{user_id}",
headers=csrf_headers(admin_session),
json={"action": "activate", "duration": "3_months"},
)
extended_expiry = datetime.fromisoformat(extended.json()["membership"]["expires_at"])
assert extended_expiry == add_months(first_expiry, 3)
disabled = await client.patch(
f"/api/admin/memberships/{user_id}",
headers=csrf_headers(admin_session),
json={"action": "disable"},
)
assert disabled.json()["membership"]["status"] == "disabled"
assert disabled.json()["membership"]["active"] is False
assert (
datetime.fromisoformat(disabled.json()["membership"]["expires_at"]) == extended_expiry
)
run_scenario(application, scenario)
def test_smart_access_allows_admin_and_member_but_not_regular_user(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/smart-access")
def smart_access_probe(_principal: SmartAccessPrincipal) -> dict[str, bool]:
return {"allowed": True}
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "smart-admin", ADMIN_PASSWORD)
client.cookies.clear()
member_response, member_session = await register(
client, "smart-member", USER_PASSWORD
)
member_id = member_response.json()["account"]["id"]
client.cookies.clear()
_, user_session = await register(client, "smart-user", USER_PASSWORD)
denied = await client.get("/api/test/smart-access")
assert denied.status_code == 403
assert denied.json()["error"]["code"] == "membership_required"
use_session(client, admin_session)
assert (await client.get("/api/test/smart-access")).status_code == 200
activated = await client.patch(
f"/api/admin/memberships/{member_id}",
headers=csrf_headers(admin_session),
json={"action": "activate", "duration": "1_month"},
)
assert activated.status_code == 200
use_session(client, member_session)
assert (await client.get("/api/test/smart-access")).status_code == 200
use_session(client, user_session)
assert (await client.get("/api/test/smart-access")).status_code == 403
run_scenario(application, scenario)
+43
View File
@@ -0,0 +1,43 @@
import pytest
from cryptography.fernet import Fernet
from backend.bootstrap.settings import ConfigurationError, Settings
from backend.security import SecretCipher, load_or_create_cipher
def test_development_cipher_persists_in_ignored_data_directory(tmp_path) -> None:
settings = Settings.for_test(tmp_path)
first = load_or_create_cipher(settings)
encrypted = first.encrypt("private profile")
second = load_or_create_cipher(settings)
assert second.decrypt(encrypted) == "private profile"
assert (tmp_path / ".encryption.key").exists()
assert "private profile" not in encrypted
def test_explicit_encryption_key_is_reusable(tmp_path) -> None:
key = Fernet.generate_key().decode("ascii")
settings = Settings.for_test(tmp_path)
configured = Settings(
environment=settings.environment,
debug=settings.debug,
data_directory=settings.data_directory,
database_path=settings.database_path,
log_file=settings.log_file,
log_level=settings.log_level,
host=settings.host,
port=settings.port,
encryption_key=key,
)
first = load_or_create_cipher(configured).encrypt("secret")
second = SecretCipher.from_key(key).decrypt(first)
assert second == "secret"
def test_invalid_encryption_key_is_rejected() -> None:
with pytest.raises(ConfigurationError):
SecretCipher.from_key("invalid")
+1 -15
View File
@@ -1,23 +1,9 @@
import asyncio
import httpx
from fastapi import Query
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from backend.http.errors import AppError
def request(application, path: str) -> httpx.Response:
async def run() -> httpx.Response:
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
return await client.get(path)
return asyncio.run(run())
from tests.support import request
def test_health_reports_runtime_environment(tmp_path) -> None:
+22 -1
View File
@@ -4,7 +4,7 @@ import sqlite3
import pytest
from backend.database import Database, Migration, MigrationError, MigrationRunner
from backend.database import MIGRATIONS, Database, Migration, MigrationError, MigrationRunner
from backend.database.repositories import DatabaseStatusRepository
@@ -104,3 +104,24 @@ def test_non_contiguous_database_history_is_rejected(tmp_path) -> None:
with pytest.raises(MigrationError, match="not contiguous"):
runner.upgrade((first, second))
def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2)
assert {
"users",
"memberships",
"sessions",
"birth_profiles",
"llm_usage_daily",
"system_credentials",
"llm_models",
"llm_configuration",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import sqlite3
import httpx
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from tests.support import run_scenario
from tests.test_accounts import (
ADMIN_PASSWORD,
USER_PASSWORD,
csrf_headers,
register,
use_session,
)
def model_payload(index: int, api_key: str | None = None) -> dict[str, str]:
return {
"display_name": f"模型 {index}",
"base_url": f"https://model-{index}.example.com/v1",
"model_identifier": f"model-{index}",
"api_key": api_key or f"secret-key-{index}",
}
def test_model_pool_is_admin_only_and_never_exposes_keys(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "model-admin", ADMIN_PASSWORD)
client.cookies.clear()
_, user_session = await register(client, "model-user", USER_PASSWORD)
denied_read = await client.get("/api/admin/models")
assert denied_read.status_code == 403
denied_write = await client.post(
"/api/admin/models",
headers=csrf_headers(user_session),
json=model_payload(1),
)
assert denied_write.status_code == 403
use_session(client, admin_session)
created = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(1, "private-alpha-key"),
)
assert created.status_code == 201
assert created.json()["is_primary"] is True
assert created.json()["is_fallback"] is False
assert created.json()["has_api_key"] is True
assert "api_key" not in created.json()
assert "private-alpha-key" not in created.text
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted_before = connection.execute(
"SELECT encrypted_api_key FROM llm_models WHERE id = 1"
).fetchone()[0]
assert encrypted_before != "private-alpha-key"
assert "private-alpha-key" not in encrypted_before
updated_payload = model_payload(1)
updated_payload.pop("api_key")
updated_payload["display_name"] = "主模型"
updated = await client.put(
"/api/admin/models/1",
headers=csrf_headers(admin_session),
json=updated_payload,
)
assert updated.status_code == 200
assert updated.json()["display_name"] == "主模型"
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted_after = connection.execute(
"SELECT encrypted_api_key FROM llm_models WHERE id = 1"
).fetchone()[0]
assert encrypted_after == encrypted_before
run_scenario(application, scenario)
def test_model_selection_deletion_guards_and_runtime_config(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "selection-admin", ADMIN_PASSWORD)
for index in (1, 2, 3):
response = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(index),
)
assert response.status_code == 201
duplicate_roles = await client.put(
"/api/admin/models/selection",
headers=csrf_headers(admin_session),
json={"primary_model_id": 2, "fallback_model_id": 2},
)
assert duplicate_roles.status_code == 400
assert duplicate_roles.json()["error"]["code"] == "duplicate_model_role"
selected = await client.put(
"/api/admin/models/selection",
headers=csrf_headers(admin_session),
json={"primary_model_id": 2, "fallback_model_id": 1},
)
assert selected.status_code == 200
models = (await client.get("/api/admin/models")).json()
assert next(item for item in models if item["id"] == 2)["is_primary"] is True
assert next(item for item in models if item["id"] == 1)["is_fallback"] is True
selected_delete = await client.delete(
"/api/admin/models/1", headers=csrf_headers(admin_session)
)
assert selected_delete.status_code == 409
assert selected_delete.json()["error"]["code"] == "model_in_use"
unselected_delete = await client.delete(
"/api/admin/models/3", headers=csrf_headers(admin_session)
)
assert unselected_delete.status_code == 200
runtime = application.state.container.model_pool.runtime_config()
assert runtime.primary.id == 2
assert runtime.fallback is not None
assert runtime.fallback.id == 1
assert (
application.state.container.model_pool.decrypt_api_key(runtime.primary)
== "secret-key-2"
)
run_scenario(application, scenario)
def test_model_pool_rejects_twenty_first_model(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "capacity-admin", ADMIN_PASSWORD)
for index in range(1, 21):
response = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(index),
)
assert response.status_code == 201
rejected = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(21),
)
assert rejected.status_code == 409
assert rejected.json()["error"]["code"] == "model_pool_full"
run_scenario(application, scenario)
+20
View File
@@ -0,0 +1,20 @@
import pytest
from backend.security import PasswordHasher, PasswordPolicyError
@pytest.mark.parametrize("password", ["onlyletters", "12345678", "short1!"])
def test_password_policy_rejects_weak_values(password: str) -> None:
with pytest.raises(PasswordPolicyError):
PasswordHasher().hash(password)
def test_password_hash_uses_independent_salts_and_verifies() -> None:
hasher = PasswordHasher()
first = hasher.hash("Valid-password-123!")
second = hasher.hash("Valid-password-123!")
assert first != second
assert "Valid-password-123!" not in first
assert hasher.verify("Valid-password-123!", first)
assert not hasher.verify("Wrong-password-123!", first)
+8
View File
@@ -32,3 +32,11 @@ def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None:
assert settings.log_file is None
assert settings.database_path.parent == tmp_path
def test_production_requires_encryption_key(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "production")
monkeypatch.delenv("APP_ENCRYPTION_KEY", raising=False)
with pytest.raises(ConfigurationError, match="APP_ENCRYPTION_KEY"):
Settings.from_environment()