From 5ef8becd259e799ead0e9bfcb7338fa01da08a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Mon, 17 Aug 2026 09:29:57 +0000 Subject: [PATCH] feat: create accounts with client IP (#40875) --- .../console/auth/email_register.py | 6 ++- api/controllers/console/auth/login.py | 4 +- api/controllers/console/auth/oauth.py | 10 ++++- api/services/account_service.py | 8 +++- .../console/auth/test_email_register.py | 3 ++ .../auth/test_email_register_language.py | 1 + .../console/auth/test_email_verification.py | 24 +++++----- .../controllers/console/auth/test_oauth.py | 16 ++++++- .../console/auth/test_oauth_timezone.py | 6 ++- .../services/test_account_service.py | 44 +++++++++++++++++++ 10 files changed, 106 insertions(+), 16 deletions(-) diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index b3377bb7019..c335fb852f4 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -184,13 +184,15 @@ class EmailRegisterResetApi(Resource): if account: raise EmailAlreadyInUseError() + ip_address = extract_remote_ip(request) account = self._create_new_account( email=normalized_email, password=req_data.password_confirm, timezone=req_data.timezone, language=req_data.language, + ip_address=ip_address, ) - token_pair = AccountService.login(account=account, session=db.session(), ip_address=extract_remote_ip(request)) + token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address) AccountService.reset_login_error_rate_limit(normalized_email) return {"result": "success", "data": token_pair.model_dump()} @@ -201,6 +203,7 @@ class EmailRegisterResetApi(Resource): password: str, timezone: str | None = None, language: str | None = None, + ip_address: str | None = None, ) -> Account: try: return AccountService.create_account_and_tenant( @@ -209,6 +212,7 @@ class EmailRegisterResetApi(Resource): password=password, interface_language=get_valid_language(language), timezone=timezone, + ip_address=ip_address, session=db.session(), ) except SeatsLimitExceededError: diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 78d7038cc76..15558a0c146 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -346,6 +346,7 @@ class EmailCodeLoginApi(Resource): else: TenantService.create_owner_tenant(account, session=db.session()) + ip_address = extract_remote_ip(request) if account is None: try: account = AccountService.create_account_and_tenant( @@ -353,6 +354,7 @@ class EmailCodeLoginApi(Resource): name=user_email, interface_language=get_valid_language(language), timezone=req_data.timezone, + ip_address=ip_address, session=db.session(), ) except WorkSpaceNotAllowedCreateError: @@ -364,7 +366,7 @@ class EmailCodeLoginApi(Resource): raise AccountInFreezeError() except WorkspacesLimitExceededError: raise WorkspacesLimitExceeded() - token_pair = AccountService.login(account, session=db.session(), ip_address=extract_remote_ip(request)) + token_pair = AccountService.login(account, session=db.session(), ip_address=ip_address) AccountService.reset_login_error_rate_limit(user_email) # Create response with cookies instead of returning tokens in body diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index 0e1b28c58f7..41b94431f9a 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -233,7 +233,13 @@ class OAuthCallback(Resource): return _redirect_with_console_session(account, target_url) try: - account, oauth_new_user = _generate_account(provider, user_info, timezone=timezone, language=language) + account, oauth_new_user = _generate_account( + provider, + user_info, + timezone=timezone, + language=language, + ip_address=extract_remote_ip(request), + ) except AccountNotFoundError: return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.") except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError): @@ -285,6 +291,7 @@ def _generate_account( user_info: OAuthUserInfo, timezone: str | None = None, language: str | None = None, + ip_address: str | None = None, ) -> tuple[Account, bool]: # Get account by openid or email. account = _get_account_by_openid_or_email(provider, user_info) @@ -322,6 +329,7 @@ def _generate_account( provider=provider, language=interface_language, timezone=timezone, + ip_address=ip_address, session=db.session(), ) diff --git a/api/services/account_service.py b/api/services/account_service.py index 3f460275e20..66ca44342da 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -448,6 +448,7 @@ class AccountService: interface_theme: str = "light", is_setup: bool | None = False, timezone: str | None = None, + ip_address: str | None = None, *, session: Session, ) -> Account: @@ -500,6 +501,7 @@ class AccountService: interface_language=interface_language, interface_theme=interface_theme, timezone=resolved_timezone, + last_login_ip=ip_address, ) session.add(account) @@ -513,6 +515,7 @@ class AccountService: interface_language: str, password: str | None = None, timezone: str | None = None, + ip_address: str | None = None, *, session: Session, ) -> Account: @@ -523,6 +526,7 @@ class AccountService: interface_language=interface_language, password=password, timezone=timezone, + ip_address=ip_address, session=session, ) @@ -1961,10 +1965,10 @@ class RegisterService: interface_language=get_valid_language(language), password=password, is_setup=True, + ip_address=ip_address, session=session, ) - account.last_login_ip = ip_address account.initialized_at = naive_utc_now() TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session) @@ -2000,6 +2004,7 @@ class RegisterService: is_setup: bool | None = False, create_workspace_required: bool | None = True, timezone: str | None = None, + ip_address: str | None = None, *, session: Session, ) -> Account: @@ -2014,6 +2019,7 @@ class RegisterService: password=password, is_setup=is_setup, timezone=timezone, + ip_address=ip_address, session=session, ) account.status = status or AccountStatus.ACTIVE diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 6c945dc5f57..9a8b4db80d0 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -154,6 +154,7 @@ class TestEmailRegisterResetApi: password="ValidPass123!", timezone=None, language=None, + ip_address="127.0.0.1", ) mock_reset_login_rate.assert_called_once_with("invitee@example.com") mock_revoke_token.assert_called_once_with("token-123") @@ -211,6 +212,7 @@ class TestEmailRegisterResetApi: password="ValidPass123!", timezone="Asia/Shanghai", language=None, + ip_address="127.0.0.1", ) mock_reset_login_rate.assert_called_once_with("invitee@example.com") mock_revoke_token.assert_called_once_with("token-123") @@ -268,6 +270,7 @@ class TestEmailRegisterResetApi: password="ValidPass123!", timezone=None, language="zh-Hans", + ip_address="127.0.0.1", ) mock_reset_login_rate.assert_called_once_with("invitee@example.com") mock_revoke_token.assert_called_once_with("token-123") diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py index 14af718590a..385ea0834fe 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register_language.py @@ -25,6 +25,7 @@ def test_create_new_account_uses_requested_language(mock_create_account): password="ValidPass123!", interface_language="zh-Hans", timezone="Asia/Shanghai", + ip_address=None, session=ANY, ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 4c8173ce80d..0279936f635 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -462,16 +462,19 @@ class TestEmailCodeLoginApi: mock_login.return_value = mock_token_pair # Act - with app.test_request_context( - "/email-code-login/validity", - method="POST", - json={ - "email": "newuser@example.com", - "code": encode_code("123456"), - "token": "valid_token", - "language": "en-US", - "timezone": "Asia/Shanghai", - }, + with ( + patch("controllers.console.auth.login.extract_remote_ip", return_value="203.0.113.10"), + app.test_request_context( + "/email-code-login/validity", + method="POST", + json={ + "email": "newuser@example.com", + "code": encode_code("123456"), + "token": "valid_token", + "language": "en-US", + "timezone": "Asia/Shanghai", + }, + ), ): api = EmailCodeLoginApi() response = api.post() @@ -483,6 +486,7 @@ class TestEmailCodeLoginApi: name="newuser@example.com", interface_language="en-US", timezone="Asia/Shanghai", + ip_address="203.0.113.10", session=ANY, ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py index b8ec042f341..114612fe5e8 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py @@ -211,11 +211,21 @@ class TestOAuthCallback: mock_generate_account.return_value = (oauth_setup["account"], True) mock_account_service.login.return_value = oauth_setup["token_pair"] - with app.test_request_context("/auth/oauth/github/callback?code=test_code"): + with ( + patch("controllers.console.auth.oauth.extract_remote_ip", return_value="203.0.113.10"), + app.test_request_context("/auth/oauth/github/callback?code=test_code"), + ): resource.get("github") oauth_setup["provider"].get_access_token.assert_called_once_with("test_code") oauth_setup["provider"].get_user_info.assert_called_once_with("access_token") + mock_generate_account.assert_called_once_with( + "github", + oauth_setup["provider"].get_user_info.return_value, + timezone=None, + language=None, + ip_address="203.0.113.10", + ) mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true") @pytest.mark.parametrize( @@ -529,6 +539,7 @@ class TestAccountGeneration: provider="github", language="en-US", timezone=None, + ip_address=None, session=ANY, ) else: @@ -563,6 +574,7 @@ class TestAccountGeneration: provider="github", language="en-US", timezone=None, + ip_address=None, session=ANY, ) @@ -595,6 +607,7 @@ class TestAccountGeneration: provider="github", language="zh-Hans", timezone="Asia/Shanghai", + ip_address=None, session=ANY, ) @@ -627,6 +640,7 @@ class TestAccountGeneration: provider="github", language="zh-Hans", timezone=None, + ip_address=None, session=ANY, ) diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py index eaf339b62a6..bb21d6f8ce9 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py @@ -56,7 +56,9 @@ def test_generate_account_registers_with_browser_timezone( user_info = OAuthUserInfo(id="github-123", name="Test User", email="User@Example.com") with app.test_request_context(headers={"Accept-Language": "zh-Hans,zh;q=0.9"}): - result, oauth_new_user = _generate_account("github", user_info, timezone="Asia/Shanghai") + result, oauth_new_user = _generate_account( + "github", user_info, timezone="Asia/Shanghai", ip_address="203.0.113.10" + ) assert result is account assert oauth_new_user is True @@ -68,6 +70,7 @@ def test_generate_account_registers_with_browser_timezone( provider="github", language="zh-Hans", timezone="Asia/Shanghai", + ip_address="203.0.113.10", session=ANY, ) mock_link_account.assert_called_once_with("github", "github-123", account, session=ANY) @@ -100,6 +103,7 @@ def test_generate_account_prefers_state_language_over_accept_language( provider="github", language="zh-Hans", timezone=None, + ip_address=None, session=ANY, ) mock_link_account.assert_called_once_with("github", "github-123", account, session=ANY) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 1467f154f28..21f183f6dda 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -224,6 +224,7 @@ class TestAccountService: interface_language="en-US", password="password123", interface_theme="light", + ip_address="203.0.113.10", session=service_session, ) account_id = result.id @@ -235,6 +236,7 @@ class TestAccountService: assert result.password is not None assert result.password_salt is not None assert result.timezone == "America/New_York" + assert result.last_login_ip == "203.0.113.10" with sqlite_session_factory() as assertion_session: persisted_account = assertion_session.get(Account, account_id) @@ -246,6 +248,7 @@ class TestAccountService: assert persisted_account.password is not None assert persisted_account.password_salt is not None assert persisted_account.timezone == "America/New_York" + assert persisted_account.last_login_ip == "203.0.113.10" def test_create_account_uses_explicit_timezone( self, @@ -338,6 +341,7 @@ class TestAccountService: assert result.password is None assert result.password_salt is None assert result.timezone is not None + assert result.last_login_ip is None with sqlite_session_factory() as assertion_session: persisted_account = assertion_session.get(Account, account_id) @@ -349,6 +353,33 @@ class TestAccountService: assert persisted_account.password is None assert persisted_account.password_salt is None assert persisted_account.timezone is not None + assert persisted_account.last_login_ip is None + + def test_update_login_info_overwrites_initial_registration_ip( + self, + sqlite_session_factory: sessionmaker[Session], + mock_external_service_dependencies: _MockDependencies, + ) -> None: + mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False + + with sqlite_session_factory() as service_session: + account = AccountService.create_account( + email="test@example.com", + name="Test User", + interface_language="en-US", + ip_address="203.0.113.10", + session=service_session, + ) + account_id = account.id + + AccountService.update_login_info(account, service_session, ip_address="203.0.113.11") + + with sqlite_session_factory() as assertion_session: + persisted_account = assertion_session.get(Account, account_id) + assert persisted_account is not None + assert persisted_account.last_login_ip == "203.0.113.11" + assert persisted_account.last_login_at is not None # ==================== Password Management Tests ==================== @@ -1448,6 +1479,7 @@ class TestRegisterService: interface_language="en-US", password="password123", is_setup=True, + ip_address="192.168.1.1", session=service_session, ) mock_create_tenant.assert_called_once_with( @@ -1555,10 +1587,20 @@ class TestRegisterService: name="Test User", interface_language="en-US", password=None, + ip_address="203.0.113.10", session=sqlite_session, ) assert result == mock_account + mock_create_account.assert_called_once_with( + email="test@example.com", + name="Test User", + interface_language="en-US", + password=None, + timezone=None, + ip_address="203.0.113.10", + session=sqlite_session, + ) mock_create_workspace.assert_called_once_with(account=mock_account, session=sqlite_session) mock_join_default_workspace.assert_called_once_with(mock_account.id) @@ -1658,6 +1700,7 @@ class TestRegisterService: name="Test User", password="password123", language="en-US", + ip_address="203.0.113.10", session=sqlite_session, ) @@ -1672,6 +1715,7 @@ class TestRegisterService: password="password123", is_setup=False, timezone=None, + ip_address="203.0.113.10", session=sqlite_session, ) mock_create_owner_tenant.assert_called_once_with(mock_account, session=sqlite_session)