mirror of
https://github.com/langgenius/dify.git
synced 2026-09-06 01:04:49 +08:00
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Application service for Console account OAuth sign-in."""
|
|
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from contextlib import AbstractContextManager
|
|
from datetime import datetime
|
|
from typing import Protocol
|
|
|
|
from services.account_email import normalize_email
|
|
from services.account_errors import (
|
|
AccountEmailDomainSuspendedError,
|
|
AccountEmailFrozenError,
|
|
InvalidOAuthInvitationError,
|
|
InvalidOAuthProviderError,
|
|
OAuthAccountBannedError,
|
|
OAuthAccountNotFoundError,
|
|
OAuthInvitationAccountMismatchError,
|
|
OAuthRegistrationError,
|
|
OAuthWorkspaceCreationNotAllowedError,
|
|
)
|
|
from services.account_ports import AccountIntegrationRepository, AccountRepository, AccountWorkspaceMembershipQuery
|
|
from services.entities.account_entities import AccountSessionTokens, AccountSnapshot
|
|
from services.entities.account_oauth_entities import (
|
|
OAuthAccountRegistration,
|
|
OAuthAuthorizationRequest,
|
|
OAuthCallbackCommand,
|
|
OAuthCallbackResult,
|
|
OAuthIdentity,
|
|
OAuthInvitation,
|
|
OAuthInvitationResult,
|
|
OAuthSignInResult,
|
|
)
|
|
|
|
_BANNED_ACCOUNT_STATUS = "banned"
|
|
_PENDING_ACCOUNT_STATUS = "pending"
|
|
|
|
|
|
class OAuthProviderGateway(Protocol):
|
|
def get_authorization_url(self, request: OAuthAuthorizationRequest) -> str: ...
|
|
|
|
def get_identity(self, code: str) -> OAuthIdentity: ...
|
|
|
|
|
|
class OAuthInvitationGateway(Protocol):
|
|
def resolve(self, invite_token: str) -> OAuthInvitation | None: ...
|
|
|
|
|
|
class OAuthAccountClaimLease(Protocol):
|
|
def ensure_owned(self) -> None: ...
|
|
|
|
|
|
class OAuthAccountClaimLock(Protocol):
|
|
def acquire(self, *, provider: str, open_id: str, email: str) -> AbstractContextManager[OAuthAccountClaimLease]: ...
|
|
|
|
def acquire_account(self, account_id: str) -> AbstractContextManager[OAuthAccountClaimLease]: ...
|
|
|
|
|
|
class OAuthAccountRegistrationGateway(Protocol):
|
|
def register(self, registration: OAuthAccountRegistration) -> str: ...
|
|
|
|
|
|
class OAuthWorkspaceGateway(Protocol):
|
|
def create_owner_workspace(self, account_id: str) -> None: ...
|
|
|
|
def try_join_default_workspace(self, account_id: str) -> None: ...
|
|
|
|
|
|
class OAuthSessionGateway(Protocol):
|
|
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: ...
|
|
|
|
|
|
class OAuthRegistrationPolicyGateway(Protocol):
|
|
def is_registration_allowed(self) -> bool: ...
|
|
|
|
def get_freeze_type(self, email: str) -> str | None: ...
|
|
|
|
|
|
class OAuthWorkspacePolicyGateway(Protocol):
|
|
def is_creation_allowed(self) -> bool: ...
|
|
|
|
|
|
class AccountOAuthService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
providers: Mapping[str, OAuthProviderGateway],
|
|
accounts: AccountRepository,
|
|
integrations: AccountIntegrationRepository,
|
|
memberships: AccountWorkspaceMembershipQuery,
|
|
invitations: OAuthInvitationGateway,
|
|
account_claims: OAuthAccountClaimLock,
|
|
registration: OAuthAccountRegistrationGateway,
|
|
workspaces: OAuthWorkspaceGateway,
|
|
sessions: OAuthSessionGateway,
|
|
registration_policy: OAuthRegistrationPolicyGateway,
|
|
workspace_policy: OAuthWorkspacePolicyGateway,
|
|
supported_languages: Sequence[str],
|
|
now: Callable[[], datetime],
|
|
) -> None:
|
|
self._providers = dict(providers)
|
|
self._accounts = accounts
|
|
self._integrations = integrations
|
|
self._memberships = memberships
|
|
self._invitations = invitations
|
|
self._account_claims = account_claims
|
|
self._registration = registration
|
|
self._workspaces = workspaces
|
|
self._sessions = sessions
|
|
self._registration_policy = registration_policy
|
|
self._workspace_policy = workspace_policy
|
|
self._supported_languages = tuple(supported_languages)
|
|
self._now = now
|
|
|
|
def start_authorization(self, provider: str, request: OAuthAuthorizationRequest) -> str:
|
|
return self._provider(provider).get_authorization_url(request)
|
|
|
|
def complete_authorization(self, command: OAuthCallbackCommand) -> OAuthCallbackResult:
|
|
provider = self._provider(command.provider)
|
|
identity = provider.get_identity(command.code)
|
|
identity_email_key = self._identity_email_key(identity.email)
|
|
|
|
with self._account_claims.acquire(
|
|
provider=command.provider,
|
|
open_id=identity.id,
|
|
email=identity_email_key,
|
|
) as identity_claim:
|
|
return self._complete_claimed_authorization(command, identity, identity_claim)
|
|
|
|
def _complete_claimed_authorization(
|
|
self,
|
|
command: OAuthCallbackCommand,
|
|
identity: OAuthIdentity,
|
|
identity_claim: OAuthAccountClaimLease,
|
|
) -> OAuthCallbackResult:
|
|
if command.invite_token is not None:
|
|
return self._complete_invitation(command, identity, identity_claim)
|
|
|
|
account = self._resolve_account(command.provider, identity)
|
|
oauth_new_user = account is None
|
|
if account is None:
|
|
identity_claim.ensure_owned()
|
|
account = self._register_account(command, identity)
|
|
identity_claim.ensure_owned()
|
|
|
|
self._ensure_account_can_login(account)
|
|
identity_claim.ensure_owned()
|
|
self._integrations.link(account.id, provider=command.provider, open_id=identity.id)
|
|
identity_claim.ensure_owned()
|
|
with self._account_claims.acquire_account(account.id) as account_claim:
|
|
if oauth_new_user:
|
|
self._provision_new_account_workspaces(account.id, account_claim)
|
|
else:
|
|
self._provision_owner_workspace_if_required(account.id, account_claim)
|
|
if account.status == _PENDING_ACCOUNT_STATUS:
|
|
account_claim.ensure_owned()
|
|
self._accounts.activate_pending(account.id, initialized_at=self._now())
|
|
account_claim.ensure_owned()
|
|
|
|
identity_claim.ensure_owned()
|
|
tokens = self._sessions.login(account.id, ip_address=command.ip_address)
|
|
return OAuthSignInResult(tokens=tokens, oauth_new_user=oauth_new_user)
|
|
|
|
def _provision_new_account_workspaces(
|
|
self,
|
|
account_id: str,
|
|
account_claim: OAuthAccountClaimLease,
|
|
) -> None:
|
|
if self._memberships.has_active_membership(account_id):
|
|
account_claim.ensure_owned()
|
|
self._workspaces.try_join_default_workspace(account_id)
|
|
account_claim.ensure_owned()
|
|
return
|
|
|
|
creation_error = OAuthWorkspaceCreationNotAllowedError()
|
|
account_claim.ensure_owned()
|
|
if self._workspace_policy.is_creation_allowed():
|
|
account_claim.ensure_owned()
|
|
try:
|
|
self._workspaces.create_owner_workspace(account_id)
|
|
except OAuthWorkspaceCreationNotAllowedError as exc:
|
|
creation_error = exc
|
|
else:
|
|
account_claim.ensure_owned()
|
|
self._workspaces.try_join_default_workspace(account_id)
|
|
account_claim.ensure_owned()
|
|
return
|
|
|
|
account_claim.ensure_owned()
|
|
self._workspaces.try_join_default_workspace(account_id)
|
|
account_claim.ensure_owned()
|
|
if self._memberships.has_active_membership(account_id):
|
|
return
|
|
raise creation_error
|
|
|
|
def _provision_owner_workspace_if_required(
|
|
self,
|
|
account_id: str,
|
|
account_claim: OAuthAccountClaimLease,
|
|
) -> None:
|
|
if self._memberships.has_active_membership(account_id):
|
|
return
|
|
account_claim.ensure_owned()
|
|
if not self._workspace_policy.is_creation_allowed():
|
|
raise OAuthWorkspaceCreationNotAllowedError
|
|
account_claim.ensure_owned()
|
|
self._workspaces.create_owner_workspace(account_id)
|
|
account_claim.ensure_owned()
|
|
|
|
def _complete_invitation(
|
|
self,
|
|
command: OAuthCallbackCommand,
|
|
identity: OAuthIdentity,
|
|
identity_claim: OAuthAccountClaimLease,
|
|
) -> OAuthCallbackResult:
|
|
invite_token = command.invite_token
|
|
if invite_token is None:
|
|
raise AssertionError("invitation completion requires a token")
|
|
invitation = self._invitations.resolve(invite_token)
|
|
if invitation is None:
|
|
raise InvalidOAuthInvitationError
|
|
if self._normalize_email(invitation.account_email) != self._normalize_email(identity.email):
|
|
raise OAuthInvitationAccountMismatchError(invite_token)
|
|
if invitation.account_status == _BANNED_ACCOUNT_STATUS:
|
|
raise OAuthAccountBannedError
|
|
|
|
identity_claim.ensure_owned()
|
|
self._integrations.link(invitation.account_id, provider=command.provider, open_id=identity.id)
|
|
identity_claim.ensure_owned()
|
|
tokens = self._sessions.login(invitation.account_id, ip_address=command.ip_address)
|
|
return OAuthInvitationResult(tokens=tokens, invite_token=invite_token)
|
|
|
|
def _register_account(self, command: OAuthCallbackCommand, identity: OAuthIdentity) -> AccountSnapshot:
|
|
normalized_email = self._normalize_email(identity.email)
|
|
if not self._registration_policy.is_registration_allowed():
|
|
freeze_type = self._registration_policy.get_freeze_type(normalized_email)
|
|
if freeze_type == "email_domain_suspended":
|
|
raise AccountEmailDomainSuspendedError
|
|
if freeze_type:
|
|
raise AccountEmailFrozenError
|
|
raise OAuthRegistrationError("Invalid email or password")
|
|
|
|
language = command.language or command.browser_language
|
|
if language not in self._supported_languages:
|
|
language = self._supported_languages[0]
|
|
account_id = self._registration.register(
|
|
OAuthAccountRegistration(
|
|
email=normalized_email,
|
|
name=identity.name or "Dify",
|
|
language=language,
|
|
timezone=command.timezone,
|
|
ip_address=command.ip_address,
|
|
)
|
|
)
|
|
account = self._accounts.get(account_id)
|
|
if account is None:
|
|
raise OAuthAccountNotFoundError
|
|
return account
|
|
|
|
def _resolve_account(self, provider: str, identity: OAuthIdentity) -> AccountSnapshot | None:
|
|
account_id = self._integrations.find_account_id(provider=provider, open_id=identity.id)
|
|
if account_id is not None:
|
|
account = self._accounts.get(account_id)
|
|
if account is not None:
|
|
return account
|
|
return self._accounts.find_by_email(identity.email)
|
|
|
|
@staticmethod
|
|
def _normalize_email(email: str) -> str:
|
|
return email.strip().lower()
|
|
|
|
@staticmethod
|
|
def _identity_email_key(email: str) -> str:
|
|
return normalize_email(email.strip())
|
|
|
|
@staticmethod
|
|
def _ensure_account_can_login(account: AccountSnapshot) -> None:
|
|
if account.status == _BANNED_ACCOUNT_STATUS:
|
|
raise OAuthAccountBannedError
|
|
|
|
def _provider(self, provider: str) -> OAuthProviderGateway:
|
|
gateway = self._providers.get(provider)
|
|
if gateway is None:
|
|
raise InvalidOAuthProviderError
|
|
return gateway
|