refactor(api): decouple token manager from account model

This commit is contained in:
Byron Wang 2026-08-11 15:41:17 +08:00
parent 3fa73d29be
commit 21f3d487ae
No known key found for this signature in database
GPG Key ID: 335E934E215AD579
9 changed files with 37 additions and 51 deletions

View File

@ -118,7 +118,7 @@ def build_application_services(
verification_lockout_duration=dify_config.CHANGE_EMAIL_LOCKOUT_DURATION,
),
email_policy=BillingAccountEmailPolicyGateway(
billing_enabled=dify_config.BILLING_ENABLED,
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
),
),
deletion=AccountDeletionService(

View File

@ -500,16 +500,13 @@ class TokenManager:
def generate_token(
cls,
token_type: str,
account: "Account | None" = None,
account_id: str | None = None,
email: str | None = None,
additional_data: dict[str, Any] | None = None,
) -> str:
if account is None and email is None:
if account_id is None and email is None:
raise ValueError("Account or email must be provided")
account_id = account.id if account else None
account_email = email if email is not None else account.email if account else None
if account_id:
old_token = cls._get_current_token_for_account(account_id, token_type)
if old_token:
@ -518,7 +515,7 @@ class TokenManager:
cls.revoke_token(old_token, token_type)
token = str(uuid.uuid4())
token_data = {"account_id": account_id, "email": account_email, "token_type": token_type}
token_data = {"account_id": account_id, "email": email, "token_type": token_type}
if additional_data:
token_data.update(additional_data)

View File

@ -1,8 +1,7 @@
"""Infrastructure adapters for the account change-email application service."""
import secrets
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, override
from typing import override
from pydantic import TypeAdapter, ValidationError
from redis import RedisError
@ -35,18 +34,9 @@ from services.entities.auth_entities import (
)
from tasks.mail_change_mail_task import send_change_mail_completed_notification_task, send_change_mail_task
if TYPE_CHECKING:
from models.account import Account
_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData)
@dataclass(frozen=True, slots=True)
class _TokenAccount:
id: str
email: str
class TokenManagerChangeEmailTokenGateway(ChangeEmailTokenGateway):
@override
def get(self, token: str) -> AccountChangeEmailTokenData | None:
@ -75,9 +65,8 @@ class TokenManagerChangeEmailTokenGateway(ChangeEmailTokenGateway):
@override
def issue(self, token_data: AccountChangeEmailTokenData) -> str:
account = cast("Account", _TokenAccount(id=token_data.account_id, email=token_data.email))
return TokenManager.generate_token(
account=account,
account_id=token_data.account_id,
email=token_data.email,
token_type="change_email",
additional_data={

View File

@ -2,8 +2,7 @@
import secrets
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, cast, override
from typing import override
from libs.helper import RateLimiter, TokenManager
from services.account_errors import AccountDeletionRateLimitError
@ -18,22 +17,14 @@ from services.entities.account_entities import AccountDeletionChallenge
from tasks.delete_account_task import delete_account_task
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
if TYPE_CHECKING:
from models.account import Account
@dataclass(frozen=True, slots=True)
class _TokenAccount:
id: str
email: str
class TokenManagerAccountDeletionVerificationGateway(AccountDeletionVerificationGateway):
@override
def create(self, *, account_id: str, email: str) -> AccountDeletionChallenge:
code = "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
token = TokenManager.generate_token(
account=cast("Account", _TokenAccount(id=account_id, email=email)),
account_id=account_id,
email=email,
token_type="account_deletion",
additional_data={"code": code},
)

View File

@ -541,7 +541,10 @@ class AccountService:
def generate_account_deletion_verification_code(account: Account) -> tuple[str, str]:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, token_type="account_deletion", additional_data={"code": code}
account_id=account.id,
email=account.email,
token_type="account_deletion",
additional_data={"code": code},
)
return token, code
@ -917,7 +920,10 @@ class AccountService:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
additional_data["code"] = code
token = TokenManager.generate_token(
account=account, email=email, token_type="reset_password", additional_data=additional_data
account_id=account.id if account else None,
email=email,
token_type="reset_password",
additional_data=additional_data,
)
return code, token
@ -941,7 +947,7 @@ class AccountService:
account: Account,
) -> str:
token = TokenManager.generate_token(
account=account,
account_id=account.id,
email=token_data.email,
token_type="change_email",
additional_data=token_data.to_token_manager_payload(),
@ -960,7 +966,10 @@ class AccountService:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
additional_data["code"] = code
token = TokenManager.generate_token(
account=account, email=email, token_type="owner_transfer", additional_data=additional_data
account_id=account.id if account else None,
email=email,
token_type="owner_transfer",
additional_data=additional_data,
)
return code, token
@ -1020,7 +1029,10 @@ class AccountService:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, email=email, token_type="email_code_login", additional_data={"code": code}
account_id=account.id if account else None,
email=email,
token_type="email_code_login",
additional_data={"code": code},
)
send_email_code_login_mail_task.delay(
language=language,

View File

@ -74,7 +74,10 @@ class WebAppAuthService:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
token = TokenManager.generate_token(
account=account, email=email, token_type="email_code_login", additional_data={"code": code}
account_id=account.id if account else None,
email=email,
token_type="email_code_login",
additional_data={"code": code},
)
send_email_code_login_mail_task.delay(
language=language,

View File

@ -61,19 +61,16 @@ def test_token_manager_roundtrip_preserves_untyped_metadata_keys(monkeypatch: py
assert data.get("custom_marker") == "preserve-me"
def test_token_manager_roundtrip_uses_explicit_email_with_account(monkeypatch: pytest.MonkeyPatch) -> None:
"""When both `account` and `email` are supplied, the token should bind the
stable `account_id` from the account and the target email from the explicit
email argument.
def test_token_manager_roundtrip_uses_explicit_account_id(monkeypatch: pytest.MonkeyPatch) -> None:
"""When both `account_id` and `email` are supplied, the token should bind
both primitive values without depending on an account model.
"""
storage: dict[str, str] = {}
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
account = SimpleNamespace(id="acc-1", email="old@example.com")
token = TokenManager.generate_token(
account=account,
account_id="acc-1",
email="new@example.com",
token_type="change_email",
additional_data={

View File

@ -39,9 +39,7 @@ def test_token_gateway_issues_account_bound_state() -> None:
) as generate_token:
assert gateway.issue(token_data) == "token"
token_account = generate_token.call_args.kwargs["account"]
assert token_account.id == "account-1"
assert token_account.email == "new@example.com"
assert generate_token.call_args.kwargs["account_id"] == "account-1"
assert generate_token.call_args.kwargs["email"] == "new@example.com"
assert generate_token.call_args.kwargs["additional_data"] == {
"old_email": "old@example.com",

View File

@ -35,9 +35,8 @@ def test_verification_gateway_creates_six_digit_account_bound_challenge() -> Non
assert challenge.token == "token"
assert challenge.code == "123456"
token_account = generate_token.call_args.kwargs["account"]
assert token_account.id == "account-1"
assert token_account.email == "account@example.com"
assert generate_token.call_args.kwargs["account_id"] == "account-1"
assert generate_token.call_args.kwargs["email"] == "account@example.com"
assert generate_token.call_args.kwargs["additional_data"] == {"code": "123456"}