施工(HEL-226): 实现登录门户与本机免密切换账号
用设备 Cookie 和授权表记住本机已验证账号,登录页按确认样图做成门户,不再把密码写进浏览器。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
8a5e78f022
commit
f0a1adf52f
@@ -28,11 +28,11 @@ class AccountApplicationMixin:
|
||||
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||
self.accounts.update_membership(payload)
|
||||
|
||||
def register_account(self, username: str, password: str) -> dict[str, Any]:
|
||||
return self.accounts.register(username, password)
|
||||
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
return self.accounts.register(username, password, device_hash)
|
||||
|
||||
def login_account(self, username: str, password: str) -> dict[str, Any]:
|
||||
return self.accounts.login(username, password)
|
||||
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
return self.accounts.login(username, password, device_hash)
|
||||
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
self.accounts.change_password(current_password, new_password)
|
||||
|
||||
@@ -1,49 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
|
||||
from backend.features.accounts.security import token_hash
|
||||
|
||||
|
||||
class AccountHttpMixin:
|
||||
def _device_hash(self) -> str:
|
||||
raw = self.device_token()
|
||||
return token_hash(raw) if raw else ""
|
||||
|
||||
def _ensure_device_token(self) -> str:
|
||||
return self.device_token() or secrets.token_urlsafe(32)
|
||||
|
||||
def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("Set-Cookie", self.session_cookie(session_token)),
|
||||
("Set-Cookie", self.device_cookie(device_raw)),
|
||||
]
|
||||
|
||||
def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None:
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
status,
|
||||
self._auth_success_headers(result["session_token"], device_raw),
|
||||
)
|
||||
|
||||
def auth_register(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
device_raw = self._ensure_device_token()
|
||||
result = self.application_service.register_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
token_hash(device_raw),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
HTTPStatus.CREATED,
|
||||
{"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def auth_login(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
device_raw = self._ensure_device_token()
|
||||
result = self.application_service.login_account(
|
||||
str(body.get("username") or ""),
|
||||
str(body.get("password") or ""),
|
||||
token_hash(device_raw),
|
||||
)
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
"authenticated": True,
|
||||
"user": result["user"],
|
||||
"csrf_token": result["csrf_token"],
|
||||
},
|
||||
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
|
||||
)
|
||||
self._send_authenticated_session(result, HTTPStatus.OK, device_raw)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||
|
||||
def auth_accounts(self) -> None:
|
||||
current_user_id = None
|
||||
if self.require_auth(send_error=False):
|
||||
current_user_id = int(self.auth_user["id"])
|
||||
payload = self.application_service.accounts.list_device_accounts(
|
||||
self._device_hash(),
|
||||
current_user_id,
|
||||
)
|
||||
self.send_json({"ok": True, **payload})
|
||||
|
||||
def auth_switch(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
try:
|
||||
user_id = int(body.get("user_id"))
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
device_raw = self.device_token()
|
||||
result = self.application_service.accounts.switch_account(
|
||||
token_hash(device_raw) if device_raw else "",
|
||||
user_id,
|
||||
)
|
||||
self._send_authenticated_session(
|
||||
result,
|
||||
HTTPStatus.OK,
|
||||
device_raw or self._ensure_device_token(),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||
|
||||
def auth_forget(self) -> None:
|
||||
try:
|
||||
body = self.read_json_body()
|
||||
try:
|
||||
user_id = int(body.get("user_id"))
|
||||
except (TypeError, ValueError):
|
||||
user_id = 0
|
||||
self.application_service.accounts.forget_account(self._device_hash(), user_id)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
self.send_json({"ok": True})
|
||||
|
||||
def auth_me(self) -> None:
|
||||
service = self.application_service
|
||||
if not self.require_auth(send_error=False):
|
||||
@@ -55,6 +114,15 @@ class AccountHttpMixin:
|
||||
}
|
||||
)
|
||||
return
|
||||
headers = None
|
||||
if not self.device_token():
|
||||
device_raw = secrets.token_urlsafe(32)
|
||||
service.accounts.remember_account(
|
||||
token_hash(device_raw),
|
||||
int(self.auth_user["id"]),
|
||||
fresh=True,
|
||||
)
|
||||
headers = [("Set-Cookie", self.device_cookie(device_raw))]
|
||||
self.send_json(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -66,15 +134,19 @@ class AccountHttpMixin:
|
||||
"membership": service.membership(),
|
||||
},
|
||||
"csrf_token": str(self.auth_user["csrf_token"]),
|
||||
}
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def auth_logout(self) -> None:
|
||||
raw_token = self.session_token()
|
||||
user_id = int(getattr(self, "auth_user", {}).get("id") or 0)
|
||||
if raw_token:
|
||||
from backend.features.accounts.security import token_hash
|
||||
|
||||
self.application_service.database.delete_session(token_hash(raw_token))
|
||||
self.application_service.accounts.revoke_current_device_grant(
|
||||
self._device_hash(),
|
||||
user_id,
|
||||
)
|
||||
self.send_json(
|
||||
{"ok": True},
|
||||
headers={"Set-Cookie": self.session_cookie("", clear=True)},
|
||||
|
||||
@@ -234,3 +234,96 @@ class AccountRepositoryMixin:
|
||||
(user_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def cleanup_expired_switch_grants(self, now: str) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE expires_at <= ?",
|
||||
(now,),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status,
|
||||
u.membership_plan, u.membership_starts_at, u.membership_expires_at,
|
||||
u.created_at, g.last_used_at, g.granted_at, g.expires_at
|
||||
FROM account_switch_grants AS g
|
||||
JOIN users AS u ON u.id = g.user_id
|
||||
WHERE g.device_hash = ? AND g.expires_at > ?
|
||||
ORDER BY g.last_used_at DESC, g.id DESC
|
||||
""",
|
||||
(device_hash, now),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None:
|
||||
with self.connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT device_hash, user_id, granted_at, last_used_at, expires_at
|
||||
FROM account_switch_grants
|
||||
WHERE device_hash = ? AND user_id = ?
|
||||
""",
|
||||
(device_hash, user_id),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_switch_grant(
|
||||
self,
|
||||
device_hash: str,
|
||||
user_id: int,
|
||||
granted_at: str,
|
||||
last_used_at: str,
|
||||
expires_at: str,
|
||||
) -> None:
|
||||
with self.connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO account_switch_grants
|
||||
(device_hash, user_id, granted_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_hash, user_id) DO UPDATE SET
|
||||
granted_at = excluded.granted_at,
|
||||
last_used_at = excluded.last_used_at,
|
||||
expires_at = excluded.expires_at
|
||||
""",
|
||||
(device_hash, user_id, granted_at, last_used_at, expires_at),
|
||||
)
|
||||
|
||||
def prune_switch_grants(self, device_hash: str, keep: int) -> int:
|
||||
with self.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT id FROM account_switch_grants
|
||||
WHERE device_hash = ?
|
||||
ORDER BY last_used_at DESC, id DESC
|
||||
""",
|
||||
(device_hash,),
|
||||
).fetchall()
|
||||
extra = [int(row["id"]) for row in rows[keep:]]
|
||||
if not extra:
|
||||
return 0
|
||||
connection.execute(
|
||||
f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})",
|
||||
extra,
|
||||
)
|
||||
return len(extra)
|
||||
|
||||
def delete_switch_grant(self, device_hash: str, user_id: int) -> bool:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?",
|
||||
(device_hash, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_switch_grants_for_user(self, user_id: int) -> int:
|
||||
with self.connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM account_switch_grants WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return int(cursor.rowcount)
|
||||
|
||||
@@ -6,6 +6,9 @@ class AccountRoutesMixin:
|
||||
if parsed.path == "/api/auth/me":
|
||||
self.auth_me()
|
||||
return True
|
||||
if parsed.path == "/api/auth/accounts":
|
||||
self.auth_accounts()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_accounts_get(self, parsed) -> bool:
|
||||
|
||||
@@ -77,15 +77,22 @@ class AccountService:
|
||||
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
|
||||
return self.membership_for_access(access)
|
||||
|
||||
def register(self, username: str, password: str) -> dict[str, Any]:
|
||||
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)
|
||||
return self.create_session(user)
|
||||
result = self.create_session(user)
|
||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||
return result
|
||||
|
||||
def login(self, username: str, password: str) -> dict[str, Any]:
|
||||
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||
username = username.strip()
|
||||
if not username or not password:
|
||||
raise ValueError("账号名和密码不能为空。")
|
||||
@@ -96,7 +103,9 @@ class AccountService:
|
||||
str(user.get("password_hash") or ""),
|
||||
):
|
||||
raise ValueError("账号名或密码不正确。")
|
||||
return self.create_session(user)
|
||||
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 "")
|
||||
@@ -112,6 +121,86 @@ class AccountService:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user