用设备 Cookie 和授权表记住本机已验证账号,登录页按确认样图做成门户,不再把密码写进浏览器。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import secrets
|
|
import threading
|
|
from collections.abc import Callable
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from backend.bootstrap.config import (
|
|
SESSION_MAX_AGE,
|
|
USERNAME_PATTERN,
|
|
add_months,
|
|
normalize_date,
|
|
parse_iso_datetime,
|
|
)
|
|
from backend.features.accounts.security import (
|
|
SecretVault,
|
|
hash_password,
|
|
token_hash,
|
|
verify_password,
|
|
)
|
|
|
|
|
|
class AccountService:
|
|
"""Preserved account, session, membership and birth-profile behavior."""
|
|
|
|
def __init__(
|
|
self,
|
|
database: Any,
|
|
vault: SecretVault,
|
|
current_user_supplier: Callable[[], int],
|
|
access_supplier: Callable[[], dict[str, Any]],
|
|
bind_user: Callable[[int], None],
|
|
personal_field_builder: Callable[..., dict[str, Any]],
|
|
auth_lock: threading.Lock,
|
|
) -> None:
|
|
self.database = database
|
|
self.vault = vault
|
|
self.current_user_supplier = current_user_supplier
|
|
self.access_supplier = access_supplier
|
|
self.bind_user = bind_user
|
|
self.personal_field_builder = personal_field_builder
|
|
self.auth_lock = auth_lock
|
|
|
|
@property
|
|
def current_user_id(self) -> int:
|
|
return int(self.current_user_supplier())
|
|
|
|
@staticmethod
|
|
def membership_for_access(access: dict[str, Any]) -> dict[str, Any]:
|
|
now = datetime.now(timezone.utc)
|
|
starts = parse_iso_datetime(access.get("membership_starts_at"))
|
|
expires = parse_iso_datetime(access.get("membership_expires_at"))
|
|
subscribed = (
|
|
access.get("membership_status") == "active"
|
|
and (not starts or starts <= now)
|
|
and (not expires or expires > now)
|
|
)
|
|
is_admin = str(access.get("role")) == "admin"
|
|
active = is_admin or subscribed
|
|
remaining_seconds = None
|
|
if expires:
|
|
remaining_seconds = max(0, int((expires - now).total_seconds()))
|
|
return {
|
|
"active": active,
|
|
"subscribed": subscribed,
|
|
"status": "active" if subscribed else str(access.get("membership_status") or "inactive"),
|
|
"plan": str(access.get("membership_plan") or ""),
|
|
"starts_at": str(access.get("membership_starts_at") or ""),
|
|
"expires_at": str(access.get("membership_expires_at") or ""),
|
|
"is_admin": is_admin,
|
|
"remaining_seconds": remaining_seconds,
|
|
"remaining_days": None if remaining_seconds is None else (remaining_seconds + 86399) // 86400,
|
|
}
|
|
|
|
def membership(self) -> dict[str, Any]:
|
|
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
|
return self.membership_for_access(access)
|
|
|
|
GRANT_SLIDE_DAYS = 30
|
|
GRANT_HARD_DAYS = 180
|
|
MAX_GRANTS_PER_DEVICE = 5
|
|
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
|
|
|
def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
|
username = username.strip()
|
|
self.validate_input(username, password)
|
|
with self.auth_lock:
|
|
salt, password_digest = hash_password(password)
|
|
user = self.database.create_user(username, salt, password_digest)
|
|
result = self.create_session(user)
|
|
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
|
return result
|
|
|
|
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
|
username = username.strip()
|
|
if not username or not password:
|
|
raise ValueError("账号名和密码不能为空。")
|
|
user = self.database.user_by_username(username)
|
|
if not user or not verify_password(
|
|
password,
|
|
str(user.get("password_salt") or ""),
|
|
str(user.get("password_hash") or ""),
|
|
):
|
|
raise ValueError("账号名或密码不正确。")
|
|
result = self.create_session(user)
|
|
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
|
return result
|
|
|
|
def change_password(self, current_password: str, new_password: str) -> None:
|
|
current_password = str(current_password or "")
|
|
access = self.database.user_access(self.current_user_id)
|
|
self.validate_input(str(access["username"]), new_password)
|
|
credentials = self.database.user_password(self.current_user_id)
|
|
if not credentials or not verify_password(
|
|
current_password,
|
|
str(credentials.get("password_salt") or ""),
|
|
str(credentials.get("password_hash") or ""),
|
|
):
|
|
raise ValueError("当前密码不正确。")
|
|
salt, digest = hash_password(new_password)
|
|
if not self.database.update_user_password(self.current_user_id, salt, digest):
|
|
raise ValueError("账号不存在。")
|
|
self.database.delete_switch_grants_for_user(self.current_user_id)
|
|
|
|
@staticmethod
|
|
def _utc_now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
@classmethod
|
|
def _iso(cls, value: datetime) -> str:
|
|
return value.isoformat(timespec="seconds")
|
|
|
|
def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None:
|
|
if not device_hash or user_id <= 0:
|
|
return
|
|
now = self._utc_now()
|
|
now_text = self._iso(now)
|
|
self.database.cleanup_expired_switch_grants(now_text)
|
|
existing = None if fresh else self.database.get_switch_grant(device_hash, user_id)
|
|
granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now
|
|
if granted_at is None:
|
|
granted_at = now
|
|
expires = min(
|
|
now + timedelta(days=self.GRANT_SLIDE_DAYS),
|
|
granted_at + timedelta(days=self.GRANT_HARD_DAYS),
|
|
)
|
|
if not existing:
|
|
self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1)
|
|
self.database.upsert_switch_grant(
|
|
device_hash,
|
|
user_id,
|
|
self._iso(granted_at),
|
|
now_text,
|
|
self._iso(expires),
|
|
)
|
|
|
|
def list_device_accounts(
|
|
self, device_hash: str, current_user_id: int | None = None
|
|
) -> dict[str, Any]:
|
|
if not device_hash:
|
|
return {"accounts": [], "current_user_id": current_user_id}
|
|
now_text = self._iso(self._utc_now())
|
|
self.database.cleanup_expired_switch_grants(now_text)
|
|
accounts = []
|
|
for row in self.database.list_switch_grants(device_hash, now_text):
|
|
accounts.append(
|
|
{
|
|
"user_id": int(row["id"]),
|
|
"username": str(row["username"]),
|
|
"role": str(row.get("role") or "user"),
|
|
"membership": self.membership_for_access(row),
|
|
"last_used_at": str(row.get("last_used_at") or ""),
|
|
}
|
|
)
|
|
return {"accounts": accounts, "current_user_id": current_user_id}
|
|
|
|
def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]:
|
|
if not device_hash or user_id <= 0:
|
|
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
|
now = self._utc_now()
|
|
now_text = self._iso(now)
|
|
self.database.cleanup_expired_switch_grants(now_text)
|
|
grant = self.database.get_switch_grant(device_hash, user_id)
|
|
expires = parse_iso_datetime(grant.get("expires_at")) if grant else None
|
|
if not grant or not expires or expires <= now:
|
|
if grant:
|
|
self.database.delete_switch_grant(device_hash, user_id)
|
|
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
|
user = self.database.user_access(user_id)
|
|
if not user:
|
|
raise PermissionError(self.SWITCH_REAUTH_MESSAGE)
|
|
result = self.create_session(user)
|
|
self.remember_account(device_hash, user_id)
|
|
return result
|
|
|
|
def forget_account(self, device_hash: str, user_id: int) -> None:
|
|
if device_hash and user_id > 0:
|
|
self.database.delete_switch_grant(device_hash, user_id)
|
|
|
|
def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None:
|
|
if device_hash and user_id > 0:
|
|
self.database.delete_switch_grant(device_hash, user_id)
|
|
|
|
def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
|
|
session_token = secrets.token_urlsafe(32)
|
|
csrf_token = secrets.token_urlsafe(24)
|
|
expires = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
|
|
self.database.create_session(
|
|
token_hash(session_token),
|
|
int(user["id"]),
|
|
csrf_token,
|
|
expires.isoformat(timespec="seconds"),
|
|
)
|
|
self.bind_user(int(user["id"]))
|
|
access = self.database.user_access(int(user["id"])) or {}
|
|
return {
|
|
"user": {
|
|
"id": int(user["id"]),
|
|
"username": str(user["username"]),
|
|
"role": str(access.get("role") or "user"),
|
|
"membership": self.membership(),
|
|
},
|
|
"session_token": session_token,
|
|
"csrf_token": csrf_token,
|
|
}
|
|
|
|
@staticmethod
|
|
def validate_input(username: str, password: str) -> None:
|
|
if not USERNAME_PATTERN.fullmatch(username):
|
|
raise ValueError("账号名应为 3 至 30 位中文、字母、数字、下划线或连字符。")
|
|
if len(password) < 8 or len(password) > 128:
|
|
raise ValueError("密码长度应为 8 至 128 位。")
|
|
if password.isalpha() or password.isdigit():
|
|
raise ValueError("密码应同时包含字母、数字或符号中的至少两类。")
|
|
|
|
def save_birth_profile(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
|
gender = str(payload.get("gender") or "unspecified").strip()
|
|
current_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
|
|
personal = self.personal_field_builder(birth_datetime, gender, current_date)
|
|
encrypted = self.vault.encrypt_json(
|
|
{"birth_datetime": birth_datetime, "gender": gender}
|
|
)
|
|
self.database.save_user_birth_profile(self.current_user_id, encrypted)
|
|
return self.public_personal_profile(personal)
|
|
|
|
def stored_birth_profile(self) -> dict[str, str] | None:
|
|
encrypted = self.database.get_user_birth_profile(self.current_user_id)
|
|
if not encrypted:
|
|
return None
|
|
payload = self.vault.decrypt_json(encrypted)
|
|
birth_datetime = str(payload.get("birth_datetime") or "").strip()
|
|
if not birth_datetime:
|
|
return None
|
|
return {
|
|
"birth_datetime": birth_datetime,
|
|
"gender": str(payload.get("gender") or "unspecified"),
|
|
}
|
|
|
|
def personal_field(
|
|
self,
|
|
current_date: str,
|
|
current_field: dict[str, Any],
|
|
public: bool = False,
|
|
) -> dict[str, Any] | None:
|
|
stored = self.stored_birth_profile()
|
|
if not stored:
|
|
return None
|
|
personal = self.personal_field_builder(
|
|
stored["birth_datetime"],
|
|
stored["gender"],
|
|
current_date,
|
|
current_field,
|
|
)
|
|
if public:
|
|
return self.public_personal_profile(personal)
|
|
personal.pop("birth", None)
|
|
return personal
|
|
|
|
@staticmethod
|
|
def public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
|
|
allowed = {
|
|
"day_master",
|
|
"ten_god_tendency",
|
|
"element_balance",
|
|
"balance_tendency",
|
|
"current",
|
|
"notice",
|
|
}
|
|
return {key: value for key, value in personal.items() if key in allowed}
|
|
|
|
def update_membership(self, payload: dict[str, Any]) -> None:
|
|
try:
|
|
user_id = int(payload.get("user_id"))
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("会员账号不正确。") from exc
|
|
status = str(payload.get("status") or "inactive")
|
|
if status not in {"active", "inactive", "suspended"}:
|
|
raise ValueError("会员状态不正确。")
|
|
access = self.database.user_access(user_id)
|
|
if not access:
|
|
raise ValueError("用户不存在。")
|
|
starts_at = None
|
|
expires_at = None
|
|
plan = ""
|
|
if status == "active":
|
|
duration = str(payload.get("duration") or "").strip()
|
|
durations = {
|
|
"1_month": (1, "1个月"),
|
|
"3_months": (3, "3个月"),
|
|
"12_months": (12, "12个月"),
|
|
"3_years": (36, "3年"),
|
|
"permanent": (0, "永久"),
|
|
}
|
|
if duration not in durations:
|
|
raise ValueError("请选择会员开通时长。")
|
|
now = datetime.now(timezone.utc)
|
|
existing_start = parse_iso_datetime(access.get("membership_starts_at"))
|
|
existing_expiry = parse_iso_datetime(access.get("membership_expires_at"))
|
|
starts = existing_start if existing_start and existing_start <= now else now
|
|
months, plan = durations[duration]
|
|
starts_at = starts.isoformat(timespec="seconds")
|
|
if months:
|
|
renewal_base = existing_expiry if existing_expiry and existing_expiry > now else now
|
|
expires_at = add_months(renewal_base, months).isoformat(timespec="seconds")
|
|
if not self.database.update_membership(
|
|
user_id, status, plan, starts_at, expires_at
|
|
):
|
|
raise ValueError("用户不存在。")
|
|
|
|
def admin_users(
|
|
self, usage_supplier: Callable[[int], int]
|
|
) -> list[dict[str, Any]]:
|
|
rows = []
|
|
for user in self.database.list_users():
|
|
membership = self.membership_for_access(user)
|
|
used = usage_supplier(int(user["id"])) if membership["active"] else 0
|
|
rows.append({
|
|
**user,
|
|
"membership_active": membership["active"],
|
|
"membership_subscribed": membership["subscribed"],
|
|
"used_today": used,
|
|
})
|
|
return rows
|