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
+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