mirror of
https://github.com/langgenius/dify.git
synced 2026-03-15 20:27:02 +00:00
Compare commits
10 Commits
deploy/dev
...
verify-ema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d1904e59 | ||
|
|
095b436621 | ||
|
|
baaf4e8041 | ||
|
|
bc41371975 | ||
|
|
9c5c935ed5 | ||
|
|
559f8263b7 | ||
|
|
59c5638342 | ||
|
|
897ffb6b35 | ||
|
|
d367a6b1e1 | ||
|
|
daa9d38788 |
@@ -133,7 +133,6 @@ class EducationAutocompleteQuery(BaseModel):
|
|||||||
class ChangeEmailSendPayload(BaseModel):
|
class ChangeEmailSendPayload(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
language: str | None = None
|
language: str | None = None
|
||||||
phase: str | None = None
|
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -547,13 +546,17 @@ class ChangeEmailSendEmailApi(Resource):
|
|||||||
account = None
|
account = None
|
||||||
user_email = None
|
user_email = None
|
||||||
email_for_sending = args.email.lower()
|
email_for_sending = args.email.lower()
|
||||||
if args.phase is not None and args.phase == "new_email":
|
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
|
||||||
if args.token is None:
|
if args.token is not None:
|
||||||
raise InvalidTokenError()
|
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
|
||||||
|
|
||||||
reset_data = AccountService.get_change_email_data(args.token)
|
reset_data = AccountService.get_change_email_data(args.token)
|
||||||
if reset_data is None:
|
if reset_data is None:
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
reset_token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||||
|
if reset_token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
|
||||||
|
raise InvalidTokenError()
|
||||||
user_email = reset_data.get("email", "")
|
user_email = reset_data.get("email", "")
|
||||||
|
|
||||||
if user_email.lower() != current_user.email.lower():
|
if user_email.lower() != current_user.email.lower():
|
||||||
@@ -573,7 +576,7 @@ class ChangeEmailSendEmailApi(Resource):
|
|||||||
email=email_for_sending,
|
email=email_for_sending,
|
||||||
old_email=user_email,
|
old_email=user_email,
|
||||||
language=language,
|
language=language,
|
||||||
phase=args.phase,
|
phase=send_phase,
|
||||||
)
|
)
|
||||||
return {"result": "success", "data": token}
|
return {"result": "success", "data": token}
|
||||||
|
|
||||||
@@ -608,12 +611,26 @@ class ChangeEmailCheckApi(Resource):
|
|||||||
AccountService.add_change_email_error_rate_limit(user_email)
|
AccountService.add_change_email_error_rate_limit(user_email)
|
||||||
raise EmailCodeError()
|
raise EmailCodeError()
|
||||||
|
|
||||||
|
phase_transitions: dict[str, str] = {
|
||||||
|
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||||
|
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||||
|
}
|
||||||
|
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||||
|
if not isinstance(token_phase, str):
|
||||||
|
raise InvalidTokenError()
|
||||||
|
refreshed_phase = phase_transitions.get(token_phase)
|
||||||
|
if refreshed_phase is None:
|
||||||
|
raise InvalidTokenError()
|
||||||
|
|
||||||
# Verified, revoke the first token
|
# Verified, revoke the first token
|
||||||
AccountService.revoke_change_email_token(args.token)
|
AccountService.revoke_change_email_token(args.token)
|
||||||
|
|
||||||
# Refresh token data by generating a new token
|
# Refresh token data by generating a new token
|
||||||
_, new_token = AccountService.generate_change_email_token(
|
_, new_token = AccountService.generate_change_email_token(
|
||||||
user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={}
|
user_email,
|
||||||
|
code=args.code,
|
||||||
|
old_email=token_data.get("old_email"),
|
||||||
|
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
|
||||||
)
|
)
|
||||||
|
|
||||||
AccountService.reset_change_email_error_rate_limit(user_email)
|
AccountService.reset_change_email_error_rate_limit(user_email)
|
||||||
@@ -643,13 +660,22 @@ class ChangeEmailResetApi(Resource):
|
|||||||
if not reset_data:
|
if not reset_data:
|
||||||
raise InvalidTokenError()
|
raise InvalidTokenError()
|
||||||
|
|
||||||
AccountService.revoke_change_email_token(args.token)
|
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||||
|
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
|
||||||
|
raise InvalidTokenError()
|
||||||
|
|
||||||
|
token_email = reset_data.get("email")
|
||||||
|
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||||
|
if normalized_token_email != normalized_new_email:
|
||||||
|
raise InvalidTokenError()
|
||||||
|
|
||||||
old_email = reset_data.get("old_email", "")
|
old_email = reset_data.get("old_email", "")
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
if current_user.email.lower() != old_email.lower():
|
if current_user.email.lower() != old_email.lower():
|
||||||
raise AccountNotFound()
|
raise AccountNotFound()
|
||||||
|
|
||||||
|
AccountService.revoke_change_email_token(args.token)
|
||||||
|
|
||||||
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
|
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
|
||||||
|
|
||||||
AccountService.send_change_email_completed_notify_email(
|
AccountService.send_change_email_completed_notify_email(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import StrEnum
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -90,12 +91,25 @@ class TokenPair(BaseModel):
|
|||||||
csrf_token: str
|
csrf_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeEmailPhase(StrEnum):
|
||||||
|
OLD = "old_email"
|
||||||
|
OLD_VERIFIED = "old_email_verified"
|
||||||
|
NEW = "new_email"
|
||||||
|
NEW_VERIFIED = "new_email_verified"
|
||||||
|
|
||||||
|
|
||||||
REFRESH_TOKEN_PREFIX = "refresh_token:"
|
REFRESH_TOKEN_PREFIX = "refresh_token:"
|
||||||
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
|
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
|
||||||
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
|
||||||
|
|
||||||
class AccountService:
|
class AccountService:
|
||||||
|
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
|
||||||
|
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD
|
||||||
|
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_VERIFIED
|
||||||
|
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW
|
||||||
|
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_VERIFIED
|
||||||
|
|
||||||
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||||
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
|
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||||
email_code_login_rate_limiter = RateLimiter(
|
email_code_login_rate_limiter = RateLimiter(
|
||||||
@@ -552,13 +566,20 @@ class AccountService:
|
|||||||
raise ValueError("Email must be provided.")
|
raise ValueError("Email must be provided.")
|
||||||
if not phase:
|
if not phase:
|
||||||
raise ValueError("phase must be provided.")
|
raise ValueError("phase must be provided.")
|
||||||
|
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
|
||||||
|
raise ValueError("phase must be one of old_email or new_email.")
|
||||||
|
|
||||||
if cls.change_email_rate_limiter.is_rate_limited(account_email):
|
if cls.change_email_rate_limiter.is_rate_limited(account_email):
|
||||||
from controllers.console.auth.error import EmailChangeRateLimitExceededError
|
from controllers.console.auth.error import EmailChangeRateLimitExceededError
|
||||||
|
|
||||||
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
|
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
|
||||||
|
|
||||||
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
|
code, token = cls.generate_change_email_token(
|
||||||
|
account_email,
|
||||||
|
account,
|
||||||
|
old_email=old_email,
|
||||||
|
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
|
||||||
|
)
|
||||||
|
|
||||||
send_change_mail_task.delay(
|
send_change_mail_task.delay(
|
||||||
language=language,
|
language=language,
|
||||||
|
|||||||
@@ -950,6 +950,16 @@ class TestWorkflowAppService:
|
|||||||
assert result_with_new_email["total"] == 3
|
assert result_with_new_email["total"] == 3
|
||||||
assert all(log.created_by_role == CreatorUserRole.ACCOUNT for log in result_with_new_email["data"])
|
assert all(log.created_by_role == CreatorUserRole.ACCOUNT for log in result_with_new_email["data"])
|
||||||
|
|
||||||
|
# Create another account in a different tenant using the original email.
|
||||||
|
# Querying by the old email should still fail for this app's tenant.
|
||||||
|
cross_tenant_account = AccountService.create_account(
|
||||||
|
email=original_email,
|
||||||
|
name=fake.name(),
|
||||||
|
interface_language="en-US",
|
||||||
|
password=fake.password(length=12),
|
||||||
|
)
|
||||||
|
TenantService.create_owner_tenant_if_not_exist(cross_tenant_account, name=fake.company())
|
||||||
|
|
||||||
# Old email unbound, is unexpected input, should raise ValueError
|
# Old email unbound, is unexpected input, should raise ValueError
|
||||||
with pytest.raises(ValueError) as exc_info:
|
with pytest.raises(ValueError) as exc_info:
|
||||||
service.get_paginate_workflow_app_logs(
|
service.get_paginate_workflow_app_logs(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from flask import Flask, g
|
from flask import Flask, g
|
||||||
|
|
||||||
|
from controllers.console.auth.error import InvalidTokenError
|
||||||
from controllers.console.workspace.account import (
|
from controllers.console.workspace.account import (
|
||||||
AccountDeleteUpdateFeedbackApi,
|
AccountDeleteUpdateFeedbackApi,
|
||||||
ChangeEmailCheckApi,
|
ChangeEmailCheckApi,
|
||||||
@@ -52,7 +53,7 @@ class TestChangeEmailSend:
|
|||||||
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||||
@patch("libs.login.check_csrf_token", return_value=None)
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
def test_should_normalize_new_email_phase(
|
def test_should_infer_new_email_phase_from_token(
|
||||||
self,
|
self,
|
||||||
mock_features,
|
mock_features,
|
||||||
mock_csrf,
|
mock_csrf,
|
||||||
@@ -68,13 +69,16 @@ class TestChangeEmailSend:
|
|||||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
mock_account = _build_account("current@example.com", "acc1")
|
mock_account = _build_account("current@example.com", "acc1")
|
||||||
mock_current_account.return_value = (mock_account, None)
|
mock_current_account.return_value = (mock_account, None)
|
||||||
mock_get_change_data.return_value = {"email": "current@example.com"}
|
mock_get_change_data.return_value = {
|
||||||
|
"email": "current@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||||
|
}
|
||||||
mock_send_email.return_value = "token-abc"
|
mock_send_email.return_value = "token-abc"
|
||||||
|
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
"/account/change-email",
|
"/account/change-email",
|
||||||
method="POST",
|
method="POST",
|
||||||
json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
|
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
|
||||||
):
|
):
|
||||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
response = ChangeEmailSendEmailApi().post()
|
response = ChangeEmailSendEmailApi().post()
|
||||||
@@ -91,6 +95,107 @@ class TestChangeEmailSend:
|
|||||||
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
||||||
mock_csrf.assert_called_once()
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
@patch("controllers.console.wraps.db")
|
||||||
|
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||||
|
@patch("controllers.console.workspace.account.db")
|
||||||
|
@patch("controllers.console.workspace.account.Session")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.get_account_by_email_with_case_fallback")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
|
||||||
|
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||||
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
|
def test_should_ignore_client_phase_and_use_old_phase_when_token_missing(
|
||||||
|
self,
|
||||||
|
mock_features,
|
||||||
|
mock_csrf,
|
||||||
|
mock_extract_ip,
|
||||||
|
mock_is_ip_limit,
|
||||||
|
mock_send_email,
|
||||||
|
mock_get_account_by_email,
|
||||||
|
mock_session_cls,
|
||||||
|
mock_account_db,
|
||||||
|
mock_current_account,
|
||||||
|
mock_db,
|
||||||
|
app,
|
||||||
|
):
|
||||||
|
_mock_wraps_db(mock_db)
|
||||||
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
|
mock_current_account.return_value = (_build_account("current@example.com", "current"), None)
|
||||||
|
existing_account = _build_account("old@example.com", "acc-old")
|
||||||
|
mock_get_account_by_email.return_value = existing_account
|
||||||
|
mock_send_email.return_value = "token-legacy"
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session_cm = MagicMock()
|
||||||
|
mock_session_cm.__enter__.return_value = mock_session
|
||||||
|
mock_session_cm.__exit__.return_value = None
|
||||||
|
mock_session_cls.return_value = mock_session_cm
|
||||||
|
mock_account_db.engine = MagicMock()
|
||||||
|
|
||||||
|
with app.test_request_context(
|
||||||
|
"/account/change-email",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "old@example.com", "language": "en-US", "phase": "new_email"},
|
||||||
|
):
|
||||||
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
|
response = ChangeEmailSendEmailApi().post()
|
||||||
|
|
||||||
|
assert response == {"result": "success", "data": "token-legacy"}
|
||||||
|
mock_get_account_by_email.assert_called_once_with("old@example.com", session=mock_session)
|
||||||
|
mock_send_email.assert_called_once_with(
|
||||||
|
account=existing_account,
|
||||||
|
email="old@example.com",
|
||||||
|
old_email="old@example.com",
|
||||||
|
language="en-US",
|
||||||
|
phase=AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||||
|
)
|
||||||
|
mock_extract_ip.assert_called_once()
|
||||||
|
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
||||||
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
@patch("controllers.console.wraps.db")
|
||||||
|
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
|
||||||
|
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||||
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
|
def test_should_reject_unverified_old_email_token_for_new_email_phase(
|
||||||
|
self,
|
||||||
|
mock_features,
|
||||||
|
mock_csrf,
|
||||||
|
mock_extract_ip,
|
||||||
|
mock_is_ip_limit,
|
||||||
|
mock_send_email,
|
||||||
|
mock_get_change_data,
|
||||||
|
mock_current_account,
|
||||||
|
mock_db,
|
||||||
|
app,
|
||||||
|
):
|
||||||
|
_mock_wraps_db(mock_db)
|
||||||
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
|
mock_account = _build_account("current@example.com", "acc1")
|
||||||
|
mock_current_account.return_value = (mock_account, None)
|
||||||
|
mock_get_change_data.return_value = {
|
||||||
|
"email": "current@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
with app.test_request_context(
|
||||||
|
"/account/change-email",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
|
||||||
|
):
|
||||||
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
|
with pytest.raises(InvalidTokenError):
|
||||||
|
ChangeEmailSendEmailApi().post()
|
||||||
|
|
||||||
|
mock_send_email.assert_not_called()
|
||||||
|
mock_extract_ip.assert_called_once()
|
||||||
|
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
||||||
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestChangeEmailValidity:
|
class TestChangeEmailValidity:
|
||||||
@patch("controllers.console.wraps.db")
|
@patch("controllers.console.wraps.db")
|
||||||
@@ -122,7 +227,12 @@ class TestChangeEmailValidity:
|
|||||||
mock_account = _build_account("user@example.com", "acc2")
|
mock_account = _build_account("user@example.com", "acc2")
|
||||||
mock_current_account.return_value = (mock_account, None)
|
mock_current_account.return_value = (mock_account, None)
|
||||||
mock_is_rate_limit.return_value = False
|
mock_is_rate_limit.return_value = False
|
||||||
mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"}
|
mock_get_data.return_value = {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"code": "1234",
|
||||||
|
"old_email": "old@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||||
|
}
|
||||||
mock_generate_token.return_value = (None, "new-token")
|
mock_generate_token.return_value = (None, "new-token")
|
||||||
|
|
||||||
with app.test_request_context(
|
with app.test_request_context(
|
||||||
@@ -138,11 +248,76 @@ class TestChangeEmailValidity:
|
|||||||
mock_add_rate.assert_not_called()
|
mock_add_rate.assert_not_called()
|
||||||
mock_revoke_token.assert_called_once_with("token-123")
|
mock_revoke_token.assert_called_once_with("token-123")
|
||||||
mock_generate_token.assert_called_once_with(
|
mock_generate_token.assert_called_once_with(
|
||||||
"user@example.com", code="1234", old_email="old@example.com", additional_data={}
|
"user@example.com",
|
||||||
|
code="1234",
|
||||||
|
old_email="old@example.com",
|
||||||
|
additional_data={
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED
|
||||||
|
},
|
||||||
)
|
)
|
||||||
mock_reset_rate.assert_called_once_with("user@example.com")
|
mock_reset_rate.assert_called_once_with("user@example.com")
|
||||||
mock_csrf.assert_called_once()
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
@patch("controllers.console.wraps.db")
|
||||||
|
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||||
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
|
def test_should_refresh_new_email_phase_to_verified(
|
||||||
|
self,
|
||||||
|
mock_features,
|
||||||
|
mock_csrf,
|
||||||
|
mock_is_rate_limit,
|
||||||
|
mock_get_data,
|
||||||
|
mock_add_rate,
|
||||||
|
mock_revoke_token,
|
||||||
|
mock_generate_token,
|
||||||
|
mock_reset_rate,
|
||||||
|
mock_current_account,
|
||||||
|
mock_db,
|
||||||
|
app,
|
||||||
|
):
|
||||||
|
_mock_wraps_db(mock_db)
|
||||||
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
|
mock_account = _build_account("old@example.com", "acc2")
|
||||||
|
mock_current_account.return_value = (mock_account, None)
|
||||||
|
mock_is_rate_limit.return_value = False
|
||||||
|
mock_get_data.return_value = {
|
||||||
|
"email": "new@example.com",
|
||||||
|
"code": "5678",
|
||||||
|
"old_email": "old@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||||
|
}
|
||||||
|
mock_generate_token.return_value = (None, "new-phase-token")
|
||||||
|
|
||||||
|
with app.test_request_context(
|
||||||
|
"/account/change-email/validity",
|
||||||
|
method="POST",
|
||||||
|
json={"email": "New@Example.com", "code": "5678", "token": "token-456"},
|
||||||
|
):
|
||||||
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
|
response = ChangeEmailCheckApi().post()
|
||||||
|
|
||||||
|
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-phase-token"}
|
||||||
|
mock_is_rate_limit.assert_called_once_with("new@example.com")
|
||||||
|
mock_add_rate.assert_not_called()
|
||||||
|
mock_revoke_token.assert_called_once_with("token-456")
|
||||||
|
mock_generate_token.assert_called_once_with(
|
||||||
|
"new@example.com",
|
||||||
|
code="5678",
|
||||||
|
old_email="old@example.com",
|
||||||
|
additional_data={
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock_reset_rate.assert_called_once_with("new@example.com")
|
||||||
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestChangeEmailReset:
|
class TestChangeEmailReset:
|
||||||
@patch("controllers.console.wraps.db")
|
@patch("controllers.console.wraps.db")
|
||||||
@@ -175,7 +350,11 @@ class TestChangeEmailReset:
|
|||||||
mock_current_account.return_value = (current_user, None)
|
mock_current_account.return_value = (current_user, None)
|
||||||
mock_is_freeze.return_value = False
|
mock_is_freeze.return_value = False
|
||||||
mock_check_unique.return_value = True
|
mock_check_unique.return_value = True
|
||||||
mock_get_data.return_value = {"old_email": "OLD@example.com"}
|
mock_get_data.return_value = {
|
||||||
|
"old_email": "OLD@example.com",
|
||||||
|
"email": "new@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||||
|
}
|
||||||
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
|
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
|
||||||
mock_update_account.return_value = mock_account_after_update
|
mock_update_account.return_value = mock_account_after_update
|
||||||
|
|
||||||
@@ -194,6 +373,106 @@ class TestChangeEmailReset:
|
|||||||
mock_send_notify.assert_called_once_with(email="new@example.com")
|
mock_send_notify.assert_called_once_with(email="new@example.com")
|
||||||
mock_csrf.assert_called_once()
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
@patch("controllers.console.wraps.db")
|
||||||
|
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
|
||||||
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
|
def test_should_reject_old_phase_token_for_reset(
|
||||||
|
self,
|
||||||
|
mock_features,
|
||||||
|
mock_csrf,
|
||||||
|
mock_is_freeze,
|
||||||
|
mock_check_unique,
|
||||||
|
mock_get_data,
|
||||||
|
mock_revoke_token,
|
||||||
|
mock_update_account,
|
||||||
|
mock_send_notify,
|
||||||
|
mock_current_account,
|
||||||
|
mock_db,
|
||||||
|
app,
|
||||||
|
):
|
||||||
|
_mock_wraps_db(mock_db)
|
||||||
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
|
current_user = _build_account("old@example.com", "acc3")
|
||||||
|
mock_current_account.return_value = (current_user, None)
|
||||||
|
mock_is_freeze.return_value = False
|
||||||
|
mock_check_unique.return_value = True
|
||||||
|
mock_get_data.return_value = {
|
||||||
|
"old_email": "OLD@example.com",
|
||||||
|
"email": "old@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||||
|
}
|
||||||
|
|
||||||
|
with app.test_request_context(
|
||||||
|
"/account/change-email/reset",
|
||||||
|
method="POST",
|
||||||
|
json={"new_email": "new@example.com", "token": "token-123"},
|
||||||
|
):
|
||||||
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
|
with pytest.raises(InvalidTokenError):
|
||||||
|
ChangeEmailResetApi().post()
|
||||||
|
|
||||||
|
mock_revoke_token.assert_not_called()
|
||||||
|
mock_update_account.assert_not_called()
|
||||||
|
mock_send_notify.assert_not_called()
|
||||||
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
@patch("controllers.console.wraps.db")
|
||||||
|
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
|
||||||
|
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
|
||||||
|
@patch("libs.login.check_csrf_token", return_value=None)
|
||||||
|
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||||
|
def test_should_reject_mismatched_new_email_for_verified_token(
|
||||||
|
self,
|
||||||
|
mock_features,
|
||||||
|
mock_csrf,
|
||||||
|
mock_is_freeze,
|
||||||
|
mock_check_unique,
|
||||||
|
mock_get_data,
|
||||||
|
mock_revoke_token,
|
||||||
|
mock_update_account,
|
||||||
|
mock_send_notify,
|
||||||
|
mock_current_account,
|
||||||
|
mock_db,
|
||||||
|
app,
|
||||||
|
):
|
||||||
|
_mock_wraps_db(mock_db)
|
||||||
|
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||||
|
current_user = _build_account("old@example.com", "acc3")
|
||||||
|
mock_current_account.return_value = (current_user, None)
|
||||||
|
mock_is_freeze.return_value = False
|
||||||
|
mock_check_unique.return_value = True
|
||||||
|
mock_get_data.return_value = {
|
||||||
|
"old_email": "OLD@example.com",
|
||||||
|
"email": "another@example.com",
|
||||||
|
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||||
|
}
|
||||||
|
|
||||||
|
with app.test_request_context(
|
||||||
|
"/account/change-email/reset",
|
||||||
|
method="POST",
|
||||||
|
json={"new_email": "new@example.com", "token": "token-789"},
|
||||||
|
):
|
||||||
|
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||||
|
with pytest.raises(InvalidTokenError):
|
||||||
|
ChangeEmailResetApi().post()
|
||||||
|
|
||||||
|
mock_revoke_token.assert_not_called()
|
||||||
|
mock_update_account.assert_not_called()
|
||||||
|
mock_send_notify.assert_not_called()
|
||||||
|
mock_csrf.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestAccountDeletionFeedback:
|
class TestAccountDeletionFeedback:
|
||||||
@patch("controllers.console.wraps.db")
|
@patch("controllers.console.wraps.db")
|
||||||
|
|||||||
@@ -58,11 +58,10 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
|
const sendEmail = async (email: string, token?: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await sendVerifyCode({
|
const res = await sendVerifyCode({
|
||||||
email,
|
email,
|
||||||
phase: isOrigin ? 'old_email' : 'new_email',
|
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
startCount()
|
startCount()
|
||||||
@@ -106,7 +105,6 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
|||||||
const sendCodeToOriginEmail = async () => {
|
const sendCodeToOriginEmail = async () => {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
email,
|
email,
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
setStep(STEP.verifyOrigin)
|
setStep(STEP.verifyOrigin)
|
||||||
}
|
}
|
||||||
@@ -162,7 +160,6 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
|
|||||||
}
|
}
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
mail,
|
mail,
|
||||||
false,
|
|
||||||
stepToken,
|
stepToken,
|
||||||
)
|
)
|
||||||
setStep(STEP.verifyNew)
|
setStep(STEP.verifyNew)
|
||||||
|
|||||||
@@ -36,13 +36,30 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
|||||||
const [stepToken, setStepToken] = useState<string>('')
|
const [stepToken, setStepToken] = useState<string>('')
|
||||||
const [newOwner, setNewOwner] = useState<string>('')
|
const [newOwner, setNewOwner] = useState<string>('')
|
||||||
const [isTransfer, setIsTransfer] = useState<boolean>(false)
|
const [isTransfer, setIsTransfer] = useState<boolean>(false)
|
||||||
|
const timerRef = React.useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const startCount = () => {
|
const startCount = () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
setTime(60)
|
setTime(60)
|
||||||
const timer = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setTime((prev) => {
|
setTime((prev) => {
|
||||||
if (prev <= 0) {
|
if (prev <= 0) {
|
||||||
clearInterval(timer)
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return prev - 1
|
return prev - 1
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ export const submitDeleteAccountFeedback = (body: { feedback: string, email: str
|
|||||||
export const getDocDownloadUrl = (doc_name: string): Promise<{ url: string }> =>
|
export const getDocDownloadUrl = (doc_name: string): Promise<{ url: string }> =>
|
||||||
get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true })
|
get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true })
|
||||||
|
|
||||||
export const sendVerifyCode = (body: { email: string, phase: string, token?: string }): Promise<CommonResponse & { data: string }> =>
|
export const sendVerifyCode = (body: { email: string, token?: string }): Promise<CommonResponse & { data: string }> =>
|
||||||
post<CommonResponse & { data: string }>('/account/change-email', { body })
|
post<CommonResponse & { data: string }>('/account/change-email', { body })
|
||||||
|
|
||||||
export const verifyEmail = (body: { email: string, code: string, token: string }): Promise<CommonResponse & { is_valid: boolean, email: string, token: string }> =>
|
export const verifyEmail = (body: { email: string, code: string, token: string }): Promise<CommonResponse & { is_valid: boolean, email: string, token: string }> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user