mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
570 lines
22 KiB
Python
570 lines
22 KiB
Python
"""Infrastructure adapters for the Console authentication application service."""
|
|
|
|
import logging
|
|
import secrets
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import override
|
|
|
|
from redis import RedisError
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from configs import dify_config
|
|
from enums import DeploymentEdition
|
|
from events.tenant_event import tenant_was_created
|
|
from extensions.ext_redis import RedisClientWrapper
|
|
from libs.datetime_utils import naive_utc_now
|
|
from libs.helper import RateLimiter, TokenManager
|
|
from libs.key_providers import generate_key_pair
|
|
from libs.passport import PassportService
|
|
from libs.token import generate_csrf_token
|
|
from models import TenantCreditPool
|
|
from models.account import (
|
|
Account,
|
|
AccountStatus,
|
|
Tenant,
|
|
TenantAccountJoin,
|
|
TenantAccountRole,
|
|
TenantPluginAutoUpgradeCategory,
|
|
TenantPluginAutoUpgradeMode,
|
|
TenantPluginAutoUpgradeStrategy,
|
|
TenantStatus,
|
|
)
|
|
from models.enums import ProviderQuotaType
|
|
from services import account_errors
|
|
from services.account_activation_service import AccountActivationRepository, InvitationTokenStore
|
|
from services.account_email import normalize_email
|
|
from services.account_login_service import (
|
|
AccountProvisioningGateway,
|
|
AccountRefreshPreparationGateway,
|
|
AccountSessionGateway,
|
|
ConsoleAuthAuditGateway,
|
|
ConsoleAuthInvitationGateway,
|
|
ConsoleAuthPolicyGateway,
|
|
ConsoleAuthSecurityGateway,
|
|
EmailCodeGateway,
|
|
HumanVerificationGateway,
|
|
ResetPasswordEmailGateway,
|
|
WorkspaceProvisioningGateway,
|
|
)
|
|
from services.billing_service import BillingService
|
|
from services.email_code_login_challenge import EmailCodeLoginChallengeStore, EmailCodeLoginChallengeUnavailableError
|
|
from services.enterprise.rbac_service import ListOption, RBACService
|
|
from services.entities.account_activation_entities import InvitationLookup
|
|
from services.entities.account_login_entities import (
|
|
AuthTokenPair,
|
|
EmailCodeChallengeStatus,
|
|
LoginInvitation,
|
|
RefreshAccountStatus,
|
|
)
|
|
from services.entities.auth_audit_entities import LoginFailureReason
|
|
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
|
from services.system_feature_service import SystemFeatureService
|
|
from services.turnstile_service import (
|
|
EMAIL_CODE_SEND_ACTION,
|
|
EMAIL_CODE_VERIFY_ACTION,
|
|
TurnstileChallengeRejectedError,
|
|
TurnstileService,
|
|
TurnstileUpstreamError,
|
|
)
|
|
from tasks.mail_email_code_login import send_email_code_login_mail_task
|
|
from tasks.mail_reset_password_task import (
|
|
send_reset_password_mail_task,
|
|
send_reset_password_mail_task_when_account_not_exist,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL = timedelta(minutes=10)
|
|
_ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
|
|
_EMAIL_CODE_LOGIN_RATE_LIMIT_ATTEMPTS = 3
|
|
_EMAIL_CODE_LOGIN_RATE_LIMIT_SECONDS = 5 * 60
|
|
_LOGIN_FAILURE_LIMIT = 5
|
|
_LOGIN_FAILURE_PREFIX = "login_error_rate_limit:"
|
|
_REFRESH_TOKEN_PREFIX = "refresh_token:"
|
|
_RESET_PASSWORD_RATE_LIMIT_ATTEMPTS = 1
|
|
_RESET_PASSWORD_RATE_LIMIT_SECONDS = 60
|
|
|
|
|
|
class AccountActivationConsoleAuthInvitationGateway(ConsoleAuthInvitationGateway):
|
|
"""Resolve login invitations through the shared activation contracts."""
|
|
|
|
def __init__(self, *, tokens: InvitationTokenStore, accounts: AccountActivationRepository) -> None:
|
|
self._tokens = tokens
|
|
self._accounts = accounts
|
|
|
|
@override
|
|
def resolve(self, *, email: str, token: str) -> LoginInvitation | None:
|
|
candidate_emails = (email,) if email == email.lower() else (email, email.lower())
|
|
for candidate_email in candidate_emails:
|
|
invitation_token = self._tokens.find(
|
|
InvitationLookup(workspace_id=None, email=candidate_email, token=token)
|
|
)
|
|
if invitation_token is None:
|
|
continue
|
|
invitation = self._accounts.resolve(invitation_token)
|
|
if invitation is not None:
|
|
return LoginInvitation(email=invitation.account_email)
|
|
return None
|
|
|
|
|
|
class DeploymentConsoleAuthPolicyGateway(ConsoleAuthPolicyGateway):
|
|
def __init__(self, *, billing_enabled: bool) -> None:
|
|
self._billing_enabled = billing_enabled
|
|
|
|
@override
|
|
def get_email_freeze_type(self, email: str) -> str | None:
|
|
if not self._billing_enabled:
|
|
return None
|
|
return BillingService.get_email_freeze_type(email)
|
|
|
|
@override
|
|
def is_registration_allowed(self) -> bool:
|
|
return SystemFeatureService.is_registration_allowed()
|
|
|
|
@override
|
|
def is_workspace_creation_allowed(self) -> bool:
|
|
return SystemFeatureService.is_workspace_creation_allowed()
|
|
|
|
@override
|
|
def has_workspace_capacity(self) -> bool:
|
|
return SystemFeatureService.get_license().workspaces.is_available()
|
|
|
|
@override
|
|
def has_account_capacity(self) -> bool:
|
|
return SystemFeatureService.get_license().seats.is_available()
|
|
|
|
|
|
class RedisConsoleAuthSecurityGateway(ConsoleAuthSecurityGateway):
|
|
def __init__(self, *, redis: RedisClientWrapper) -> None:
|
|
self._redis = redis
|
|
|
|
@override
|
|
def is_login_limited(self, email: str) -> bool:
|
|
try:
|
|
count = self._redis.get(f"{_LOGIN_FAILURE_PREFIX}{email}")
|
|
return count is not None and int(count) > _LOGIN_FAILURE_LIMIT
|
|
except RedisError:
|
|
return False
|
|
|
|
@override
|
|
def record_login_failure(self, email: str) -> None:
|
|
try:
|
|
key = f"{_LOGIN_FAILURE_PREFIX}{email}"
|
|
count = int(self._redis.get(key) or 0) + 1
|
|
self._redis.setex(key, dify_config.LOGIN_LOCKOUT_DURATION, count)
|
|
except RedisError:
|
|
return None
|
|
|
|
@override
|
|
def reset_login_failures(self, email: str) -> None:
|
|
try:
|
|
self._redis.delete(f"{_LOGIN_FAILURE_PREFIX}{email}")
|
|
except RedisError:
|
|
return None
|
|
|
|
@override
|
|
def is_email_send_ip_limited(self, ip_address: str) -> bool:
|
|
minute_key = f"email_send_ip_limit_minute:{ip_address}"
|
|
freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
|
|
hour_limit_key = f"email_send_ip_limit_hour:{ip_address}"
|
|
try:
|
|
if self._redis.get(freeze_key):
|
|
return True
|
|
|
|
current_minute_count = int(self._redis.get(minute_key) or 0)
|
|
if current_minute_count > dify_config.EMAIL_SEND_IP_LIMIT_PER_MINUTE:
|
|
hour_limit_count = int(self._redis.get(hour_limit_key) or 0)
|
|
if hour_limit_count >= 1:
|
|
self._redis.setex(freeze_key, 60 * 60, 1)
|
|
return True
|
|
if not self._redis.set(hour_limit_key, 1, ex=60 * 10, nx=True):
|
|
self._redis.setex(freeze_key, 60 * 60, 1)
|
|
return True
|
|
|
|
self._redis.setex(minute_key, 60, current_minute_count + 1)
|
|
self._redis.expire(minute_key, 60)
|
|
return False
|
|
except RedisError:
|
|
return False
|
|
|
|
|
|
class TurnstileHumanVerificationGateway(HumanVerificationGateway):
|
|
@override
|
|
def verify(self, *, token: str | None, remote_ip: str, action: str) -> None:
|
|
is_email_code_verification = action == "signin_code_verify"
|
|
expected_action = EMAIL_CODE_VERIFY_ACTION if is_email_code_verification else EMAIL_CODE_SEND_ACTION
|
|
try:
|
|
TurnstileService.verify(token=token, remote_ip=remote_ip, expected_action=expected_action)
|
|
except TurnstileChallengeRejectedError as error:
|
|
if is_email_code_verification:
|
|
logger.info("Turnstile rejected an email-code verification challenge")
|
|
else:
|
|
logger.info("Turnstile rejected an email-code login challenge")
|
|
raise account_errors.HumanVerificationRejectedError from error
|
|
except TurnstileUpstreamError as error:
|
|
logger.warning("Turnstile verification is unavailable", exc_info=True)
|
|
raise account_errors.HumanVerificationUnavailableError from error
|
|
|
|
|
|
class RedisAccountSessionGateway(AccountSessionGateway):
|
|
def __init__(self, *, redis: RedisClientWrapper) -> None:
|
|
self._redis = redis
|
|
|
|
@override
|
|
def issue(self, account_id: str) -> AuthTokenPair:
|
|
return self._issue(account_id)
|
|
|
|
@override
|
|
def revoke(self, account_id: str) -> None:
|
|
refresh_token = self._redis.get(self._account_refresh_token_key(account_id))
|
|
if refresh_token:
|
|
self._delete_refresh_token(refresh_token.decode("utf-8"), account_id)
|
|
|
|
@override
|
|
def resolve_refresh_token(self, refresh_token: str) -> str | None:
|
|
account_id = self._redis.get(self._refresh_token_key(refresh_token))
|
|
return account_id.decode("utf-8") if account_id else None
|
|
|
|
@override
|
|
def rotate(self, *, refresh_token: str, account_id: str) -> AuthTokenPair:
|
|
self._delete_refresh_token(refresh_token, account_id)
|
|
return self._issue(account_id)
|
|
|
|
def _issue(self, account_id: str) -> AuthTokenPair:
|
|
expires_at = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
access_token = PassportService().issue(
|
|
{
|
|
"user_id": account_id,
|
|
"exp": int(expires_at.timestamp()),
|
|
"iss": dify_config.DEPLOYMENT_EDITION.value,
|
|
"sub": "Console API Passport",
|
|
}
|
|
)
|
|
refresh_token = secrets.token_hex(64)
|
|
self._store_refresh_token(refresh_token, account_id)
|
|
return AuthTokenPair(
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
csrf_token=generate_csrf_token(account_id),
|
|
)
|
|
|
|
def _store_refresh_token(self, refresh_token: str, account_id: str) -> None:
|
|
expires_in = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
|
self._redis.setex(self._refresh_token_key(refresh_token), expires_in, account_id)
|
|
self._redis.setex(self._account_refresh_token_key(account_id), expires_in, refresh_token)
|
|
|
|
def _delete_refresh_token(self, refresh_token: str, account_id: str) -> None:
|
|
self._redis.delete(self._refresh_token_key(refresh_token))
|
|
self._redis.delete(self._account_refresh_token_key(account_id))
|
|
|
|
@staticmethod
|
|
def _refresh_token_key(refresh_token: str) -> str:
|
|
return f"{_REFRESH_TOKEN_PREFIX}{refresh_token}"
|
|
|
|
@staticmethod
|
|
def _account_refresh_token_key(account_id: str) -> str:
|
|
return f"{_ACCOUNT_REFRESH_TOKEN_PREFIX}{account_id}"
|
|
|
|
|
|
class SQLAlchemyAccountRefreshPreparationGateway(AccountRefreshPreparationGateway):
|
|
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
|
self._session_factory = session_factory
|
|
|
|
@override
|
|
def prepare(self, account_id: str) -> RefreshAccountStatus:
|
|
now = naive_utc_now()
|
|
with self._session_factory.begin() as session:
|
|
account = session.get(Account, account_id)
|
|
if account is None:
|
|
return RefreshAccountStatus.NOT_FOUND
|
|
if account.status == AccountStatus.BANNED:
|
|
return RefreshAccountStatus.BANNED
|
|
|
|
current_membership = session.scalar(
|
|
select(TenantAccountJoin)
|
|
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
|
|
.where(
|
|
TenantAccountJoin.account_id == account_id,
|
|
TenantAccountJoin.current.is_(True),
|
|
Tenant.status == TenantStatus.NORMAL,
|
|
)
|
|
.limit(1)
|
|
)
|
|
if current_membership is None:
|
|
session.execute(
|
|
update(TenantAccountJoin)
|
|
.where(TenantAccountJoin.account_id == account_id, TenantAccountJoin.current.is_(True))
|
|
.values(current=False)
|
|
)
|
|
current_membership = session.scalar(
|
|
select(TenantAccountJoin)
|
|
.join(Tenant, Tenant.id == TenantAccountJoin.tenant_id)
|
|
.where(
|
|
TenantAccountJoin.account_id == account_id,
|
|
Tenant.status == TenantStatus.NORMAL,
|
|
)
|
|
.order_by(TenantAccountJoin.id.asc())
|
|
.limit(1)
|
|
)
|
|
if current_membership is None:
|
|
return RefreshAccountStatus.NOT_FOUND
|
|
current_membership.current = True
|
|
current_membership.last_opened_at = now
|
|
|
|
session.execute(
|
|
update(Account)
|
|
.where(
|
|
Account.id == account_id,
|
|
Account.last_active_at < now - _ACCOUNT_LAST_ACTIVE_REFRESH_INTERVAL,
|
|
)
|
|
.values(last_active_at=now)
|
|
)
|
|
return RefreshAccountStatus.READY
|
|
|
|
|
|
class SQLAlchemyConsoleAuthProvisioningGateway(AccountProvisioningGateway, WorkspaceProvisioningGateway):
|
|
"""Persist the account/workspace aggregate and own its transaction boundary."""
|
|
|
|
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
|
self._session_factory = session_factory
|
|
|
|
@override
|
|
def create_with_owner_workspace(
|
|
self,
|
|
*,
|
|
email: str,
|
|
name: str,
|
|
interface_language: str,
|
|
timezone: str,
|
|
ip_address: str,
|
|
) -> str:
|
|
normalized_email = normalize_email(email)
|
|
account = Account(
|
|
name=name,
|
|
email=email,
|
|
normalized_email=normalized_email,
|
|
interface_language=interface_language,
|
|
interface_theme="light",
|
|
timezone=timezone,
|
|
last_login_ip=ip_address,
|
|
)
|
|
with self._session_factory.begin() as session:
|
|
normalized_email_in_use = session.scalar(
|
|
select(Account.id).where(Account.normalized_email == normalized_email).limit(1)
|
|
)
|
|
if normalized_email_in_use is not None:
|
|
raise account_errors.AccountNormalizedEmailAlreadyInUseError
|
|
session.add(account)
|
|
tenant = self._add_owner_workspace(session, account)
|
|
self._bind_owner_rbac_role(tenant, account.id, session)
|
|
|
|
self._after_workspace_created(tenant, account.id)
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
|
|
from services.enterprise.enterprise_service import try_join_default_workspace
|
|
|
|
try_join_default_workspace(account.id)
|
|
return account.id
|
|
|
|
@override
|
|
def create_owner_workspace(self, account_id: str) -> None:
|
|
with self._session_factory.begin() as session:
|
|
account = session.scalar(select(Account).where(Account.id == account_id).with_for_update())
|
|
if account is None:
|
|
raise account_errors.AccountNotFoundError
|
|
existing_workspace_id = session.scalar(
|
|
select(Tenant.id)
|
|
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
|
|
.where(
|
|
TenantAccountJoin.account_id == account_id,
|
|
Tenant.status == TenantStatus.NORMAL,
|
|
)
|
|
.limit(1)
|
|
)
|
|
if existing_workspace_id is not None:
|
|
return
|
|
tenant = self._add_owner_workspace(session, account)
|
|
self._bind_owner_rbac_role(tenant, account.id, session)
|
|
|
|
self._after_workspace_created(tenant, account.id)
|
|
|
|
@staticmethod
|
|
def _add_owner_workspace(session: Session, account: Account) -> Tenant:
|
|
tenant = Tenant(name=f"{account.name}'s Workspace")
|
|
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
|
session.add(tenant)
|
|
session.add(
|
|
TenantAccountJoin(
|
|
tenant_id=tenant.id,
|
|
account_id=account.id,
|
|
role=TenantAccountRole.OWNER,
|
|
)
|
|
)
|
|
for category in TenantPluginAutoUpgradeCategory:
|
|
session.add(
|
|
TenantPluginAutoUpgradeStrategy(
|
|
tenant_id=tenant.id,
|
|
category=category,
|
|
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
|
|
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
|
|
upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE,
|
|
exclude_plugins=[],
|
|
include_plugins=[],
|
|
)
|
|
)
|
|
session.add(
|
|
TenantCreditPool(
|
|
tenant_id=tenant.id,
|
|
quota_limit=dify_config.HOSTED_POOL_CREDITS,
|
|
quota_used=0,
|
|
pool_type=ProviderQuotaType.TRIAL,
|
|
)
|
|
)
|
|
return tenant
|
|
|
|
def _bind_owner_rbac_role(self, tenant: Tenant, account_id: str, session: Session) -> None:
|
|
if dify_config.RBAC_ENABLED:
|
|
owner_role_id = self._resolve_owner_role_id(tenant.id, account_id)
|
|
RBACService.MemberRoles.replace(
|
|
tenant_id=tenant.id,
|
|
account_id=account_id,
|
|
member_account_id=account_id,
|
|
role_ids=[owner_role_id],
|
|
session=session,
|
|
)
|
|
|
|
def _after_workspace_created(self, tenant: Tenant, account_id: str) -> None:
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
|
BillingService.clean_billing_info_cache(tenant.id)
|
|
tenant_was_created.send(tenant)
|
|
|
|
@staticmethod
|
|
def _resolve_owner_role_id(tenant_id: str, account_id: str) -> str:
|
|
roles = RBACService.Roles.list(
|
|
tenant_id,
|
|
account_id,
|
|
options=ListOption(page_number=1, results_per_page=100),
|
|
).data
|
|
for role in roles:
|
|
if role.is_builtin and role.category == "global_system_default" and role.role_tag == "owner":
|
|
return role.id
|
|
raise ValueError(f"Builtin RBAC owner role not found in tenant {tenant_id}")
|
|
|
|
|
|
class RedisEmailCodeGateway(EmailCodeGateway):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
redis: RedisClientWrapper | None = None,
|
|
rate_limiter: RateLimiter | None = None,
|
|
) -> None:
|
|
if rate_limiter is None:
|
|
if redis is None:
|
|
raise ValueError("redis is required when rate_limiter is not provided")
|
|
rate_limiter = RateLimiter(
|
|
prefix="email_code_login_rate_limit",
|
|
max_attempts=_EMAIL_CODE_LOGIN_RATE_LIMIT_ATTEMPTS,
|
|
time_window=_EMAIL_CODE_LOGIN_RATE_LIMIT_SECONDS,
|
|
redis_client=redis,
|
|
)
|
|
self._rate_limiter = rate_limiter
|
|
|
|
@override
|
|
def send(
|
|
self,
|
|
*,
|
|
account_id: str | None,
|
|
normalized_email: str,
|
|
recipient_email: str,
|
|
language: str,
|
|
) -> str:
|
|
normalized_email = normalized_email.lower()
|
|
if self._rate_limiter.is_rate_limited(normalized_email):
|
|
retry_after = int(self._rate_limiter.time_window / 60)
|
|
raise account_errors.EmailCodeSendRateLimitError(retry_after)
|
|
|
|
code = "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
|
|
try:
|
|
token = EmailCodeLoginChallengeStore.create(account_id=account_id, email=normalized_email, code=code)
|
|
except EmailCodeLoginChallengeUnavailableError as error:
|
|
logger.warning("Email-code challenge creation is unavailable", exc_info=True)
|
|
raise account_errors.EmailCodeLoginUnavailableError from error
|
|
send_email_code_login_mail_task.delay(language=language, to=recipient_email, code=code)
|
|
self._rate_limiter.increment_rate_limit(normalized_email)
|
|
return token
|
|
|
|
@override
|
|
def verify(self, *, normalized_email: str, code: str, token: str) -> EmailCodeChallengeStatus:
|
|
try:
|
|
result = EmailCodeLoginChallengeStore.verify(email=normalized_email.lower(), code=code, token=token)
|
|
except EmailCodeLoginChallengeUnavailableError as error:
|
|
logger.warning("Email-code challenge verification is unavailable", exc_info=True)
|
|
raise account_errors.EmailCodeLoginUnavailableError from error
|
|
return EmailCodeChallengeStatus(result.status.value)
|
|
|
|
|
|
class RedisResetPasswordEmailGateway(ResetPasswordEmailGateway):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
redis: RedisClientWrapper | None = None,
|
|
rate_limiter: RateLimiter | None = None,
|
|
) -> None:
|
|
if rate_limiter is None:
|
|
if redis is None:
|
|
raise ValueError("redis is required when rate_limiter is not provided")
|
|
rate_limiter = RateLimiter(
|
|
prefix="reset_password_rate_limit",
|
|
max_attempts=_RESET_PASSWORD_RATE_LIMIT_ATTEMPTS,
|
|
time_window=_RESET_PASSWORD_RATE_LIMIT_SECONDS,
|
|
redis_client=redis,
|
|
)
|
|
self._rate_limiter = rate_limiter
|
|
|
|
@override
|
|
def send(
|
|
self,
|
|
*,
|
|
account_id: str | None,
|
|
email: str,
|
|
language: str,
|
|
registration_allowed: bool,
|
|
) -> str:
|
|
if self._rate_limiter.is_rate_limited(email):
|
|
raise account_errors.ResetPasswordEmailRateLimitError(int(self._rate_limiter.time_window / 60))
|
|
|
|
code = "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
|
|
token = TokenManager.generate_token(
|
|
account_id=account_id,
|
|
email=email,
|
|
token_type="reset_password",
|
|
additional_data={"code": code},
|
|
)
|
|
if account_id is not None:
|
|
send_reset_password_mail_task.delay(language=language, to=email, code=code)
|
|
else:
|
|
send_reset_password_mail_task_when_account_not_exist.delay(
|
|
language=language,
|
|
to=email,
|
|
is_allow_register=registration_allowed,
|
|
)
|
|
self._rate_limiter.increment_rate_limit(email)
|
|
return token
|
|
|
|
|
|
class LoggingConsoleAuthAuditGateway(ConsoleAuthAuditGateway):
|
|
@override
|
|
def login_failed(
|
|
self,
|
|
*,
|
|
email: str,
|
|
reason: LoginFailureReason,
|
|
ip_address: str,
|
|
) -> None:
|
|
logger.warning(
|
|
"Console login failed: email=%s reason=%s ip_address=%s",
|
|
email,
|
|
reason,
|
|
ip_address,
|
|
)
|