xiaolongmr commited on
Commit
0ecaf58
·
1 Parent(s): 3036b75

Improve email code account import flow

Browse files
services/account_email_login_service.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
  import threading
4
  import time
5
  import uuid
 
6
  from dataclasses import dataclass
7
  from datetime import datetime, timezone
8
  from typing import Any
@@ -20,7 +21,7 @@ CHATGPT_OPENAI_SCOPE = "openid email profile offline_access model.request model.
20
 
21
 
22
  def _clean(value: object) -> str:
23
- return str(value or "").strip()
24
 
25
 
26
  def _now_iso() -> str:
@@ -90,6 +91,7 @@ class PendingEmailLogin:
90
  signup_name: str = ""
91
  signup_birthdate: str = ""
92
  signup_created: bool = False
 
93
 
94
  def close(self) -> None:
95
  try:
@@ -351,6 +353,52 @@ class AccountEmailLoginImportService:
351
  + (f": {message}" if message else "")
352
  )
353
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  def _register_new_chatgpt_user(
355
  self,
356
  session: Any,
@@ -400,13 +448,31 @@ class AccountEmailLoginImportService:
400
  raise RuntimeError(f"注册验证码发送失败:{message}")
401
  raise RuntimeError(error or f"注册验证码发送失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  def _create_signup_profile(
404
  self,
405
  session: Any,
406
  device_id: str,
407
  name: str,
408
  birthdate: str,
409
- ) -> None:
410
  headers = dict(openai_register.common_headers)
411
  headers["referer"] = f"{openai_register.auth_base}/about-you"
412
  headers["oai-device-id"] = device_id
@@ -430,6 +496,173 @@ class AccountEmailLoginImportService:
430
  if message:
431
  raise RuntimeError(f"创建账号资料失败:{message}")
432
  raise RuntimeError(error or f"创建账号资料失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
 
434
  def _fetch_chatgpt_session(self, session: Any) -> dict[str, Any]:
435
  headers = {
@@ -613,20 +846,27 @@ class AccountEmailLoginImportService:
613
  first_name, last_name = openai_register._random_name()
614
  birthdate = openai_register._random_birthdate()
615
  display_name = f"{first_name} {last_name}"
 
616
  try:
617
- self._register_new_chatgpt_user(login_session, login_device_id, normalized_email, password)
618
- self._send_signup_otp(login_session)
 
619
  except RuntimeError as exc:
 
 
 
 
 
620
  raise RuntimeError(
621
  f"当前邮箱没有进入临时登录代码流程,且自动注册新账号失败:{exc}"
622
  ) from exc
623
  session_id = uuid.uuid4().hex[:12]
624
  pending = PendingEmailLogin(
625
  session_id=session_id,
626
- session=login_session,
627
  email=normalized_email,
628
- device_id=login_device_id,
629
- code_verifier="",
630
  continue_url=continue_url,
631
  created_at=time.time(),
632
  updated_at=time.time(),
@@ -638,7 +878,6 @@ class AccountEmailLoginImportService:
638
  with self._lock:
639
  self._prune_locked()
640
  self._pending[session_id] = pending
641
- should_keep_session = True
642
  return {
643
  "status": "pending_code",
644
  "session_id": session_id,
@@ -674,32 +913,66 @@ class AccountEmailLoginImportService:
674
  resp, reason = openai_register.validate_otp(pending.session, pending.device_id, normalized_code)
675
  if resp is None or resp.status_code != 200:
676
  data = _response_json(resp) if resp is not None else {}
677
- message = _error_message(data)
678
- raise ValueError(reason or message or "验证码校验失败")
679
  otp_payload = _response_json(resp)
680
  pending.continue_url = _continue_url(otp_payload) or pending.continue_url
681
  pending.otp_verified = True
682
  pending.updated_at = time.time()
683
 
684
  if pending.signup_password and not pending.signup_created:
685
- self._create_signup_profile(
686
  pending.session,
687
  pending.device_id,
688
  pending.signup_name or "ChatGPT User",
689
  pending.signup_birthdate or openai_register._random_birthdate(),
690
  )
 
691
  pending.signup_created = True
692
  pending.updated_at = time.time()
693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  continue_url = pending.continue_url or f"{openai_register.auth_base}/sign-in-with-chatgpt/codex/consent"
695
  try:
696
- self._complete_chatgpt_login_session(pending.session, continue_url)
697
- session_data = self._fetch_chatgpt_session(pending.session)
698
- result = self._import_chatgpt_session_account(
699
- session_data,
700
- cookie_names=self._chatgpt_cookie_names(pending.session),
701
- owner=pending.owner or owner,
702
- )
 
 
 
 
703
  with self._lock:
704
  self._close_pending_locked(normalized_session_id)
705
  return {
 
3
  import threading
4
  import time
5
  import uuid
6
+ import secrets
7
  from dataclasses import dataclass
8
  from datetime import datetime, timezone
9
  from typing import Any
 
21
 
22
 
23
  def _clean(value: object) -> str:
24
+ return str(value or "").strip().lstrip("\ufeff")
25
 
26
 
27
  def _now_iso() -> str:
 
91
  signup_name: str = ""
92
  signup_birthdate: str = ""
93
  signup_created: bool = False
94
+ signup_login_started: bool = False
95
 
96
  def close(self) -> None:
97
  try:
 
353
  + (f": {message}" if message else "")
354
  )
355
 
356
+ def _start_signup_registration(self, email: str) -> tuple[Any, str, str]:
357
+ session = openai_register.create_session(self._proxy())
358
+ device_id = str(uuid.uuid4())
359
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
360
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
361
+ code_verifier, code_challenge = openai_register._generate_pkce()
362
+ params = {
363
+ "issuer": openai_register.auth_base,
364
+ "client_id": openai_register.platform_oauth_client_id,
365
+ "audience": openai_register.platform_oauth_audience,
366
+ "redirect_uri": openai_register.platform_oauth_redirect_uri,
367
+ "device_id": device_id,
368
+ "screen_hint": "login_or_signup",
369
+ "max_age": "0",
370
+ "login_hint": email,
371
+ "scope": "openid profile email offline_access",
372
+ "response_type": "code",
373
+ "response_mode": "query",
374
+ "state": secrets.token_urlsafe(32),
375
+ "nonce": secrets.token_urlsafe(32),
376
+ "code_challenge": code_challenge,
377
+ "code_challenge_method": "S256",
378
+ "auth0Client": openai_register.platform_auth0_client,
379
+ }
380
+ headers = dict(openai_register.navigate_headers)
381
+ headers["referer"] = f"{openai_register.platform_base}/"
382
+ resp, error = openai_register.request_with_local_retry(
383
+ session,
384
+ "get",
385
+ f"{openai_register.auth_base}/api/accounts/authorize?{urlencode(params)}",
386
+ headers=headers,
387
+ allow_redirects=True,
388
+ verify=False,
389
+ )
390
+ if resp is None or resp.status_code != 200:
391
+ data = _response_json(resp) if resp is not None else {}
392
+ message = _error_message(data) or _response_excerpt(resp)
393
+ try:
394
+ session.close()
395
+ except Exception:
396
+ pass
397
+ if message:
398
+ raise RuntimeError(f"新账号注册授权失败:{message}")
399
+ raise RuntimeError(error or f"新账号注册授权失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
400
+ return session, device_id, code_verifier
401
+
402
  def _register_new_chatgpt_user(
403
  self,
404
  session: Any,
 
448
  raise RuntimeError(f"注册验证码发送失败:{message}")
449
  raise RuntimeError(error or f"注册验证码发送失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
450
 
451
+ def _send_login_otp(self, session: Any) -> None:
452
+ headers = dict(openai_register.navigate_headers)
453
+ headers["referer"] = f"{openai_register.auth_base}/log-in/password"
454
+ resp, error = openai_register.request_with_local_retry(
455
+ session,
456
+ "get",
457
+ f"{openai_register.auth_base}/api/accounts/email-otp/send",
458
+ headers=headers,
459
+ allow_redirects=True,
460
+ verify=False,
461
+ )
462
+ if resp is None or resp.status_code not in (200, 302):
463
+ data = _response_json(resp) if resp is not None else {}
464
+ message = _error_message(data) or _response_excerpt(resp)
465
+ if message:
466
+ raise RuntimeError(f"登录验证码发送失败:{message}")
467
+ raise RuntimeError(error or f"登录验证码发送失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
468
+
469
  def _create_signup_profile(
470
  self,
471
  session: Any,
472
  device_id: str,
473
  name: str,
474
  birthdate: str,
475
+ ) -> str:
476
  headers = dict(openai_register.common_headers)
477
  headers["referer"] = f"{openai_register.auth_base}/about-you"
478
  headers["oai-device-id"] = device_id
 
496
  if message:
497
  raise RuntimeError(f"创建账号资料失败:{message}")
498
  raise RuntimeError(error or f"创建账号资料失败:HTTP {getattr(resp, 'status_code', 'unknown')}")
499
+ data = _response_json(resp)
500
+ return _continue_url(data) or _clean(getattr(resp, "headers", {}).get("Location") if resp is not None else "")
501
+
502
+ def _import_platform_token_account(
503
+ self,
504
+ tokens: dict[str, Any],
505
+ email: str,
506
+ owner: dict[str, object] | None = None,
507
+ ) -> dict[str, Any]:
508
+ access_token = _clean(tokens.get("access_token"))
509
+ if not access_token:
510
+ raise RuntimeError("新账号注册完成,但没有换取到 access_token")
511
+ item = {
512
+ "email": _clean(tokens.get("email")) or email,
513
+ "access_token": access_token,
514
+ "refresh_token": _clean(tokens.get("refresh_token")),
515
+ "id_token": _clean(tokens.get("id_token")),
516
+ "created_at": _now_iso(),
517
+ }
518
+ add_result = account_service.add_account_items([item], owner=owner)
519
+ refresh_result = account_service.refresh_accounts([access_token])
520
+ visible_items = account_service.list_accounts_for_identity(owner) if owner else refresh_result.get("items", add_result.get("items", []))
521
+ return {
522
+ **add_result,
523
+ "refreshed": refresh_result.get("refreshed", 0),
524
+ "errors": refresh_result.get("errors", []),
525
+ "items": visible_items,
526
+ }
527
+
528
+ def _exchange_signup_tokens(self, pending: PendingEmailLogin) -> dict[str, Any]:
529
+ continue_url = pending.continue_url or f"{openai_register.auth_base}/sign-in-with-chatgpt/codex/consent"
530
+ tokens = openai_register.exchange_platform_tokens(
531
+ pending.session,
532
+ pending.device_id,
533
+ pending.code_verifier,
534
+ continue_url,
535
+ )
536
+ if not tokens:
537
+ raise RuntimeError("新账号注册完成,但 token 换取失败,请稍后重试或使用 Session JSON 导入")
538
+ return tokens
539
+
540
+ def _start_signup_password_login(
541
+ self,
542
+ email: str,
543
+ password: str,
544
+ ) -> tuple[Any, str, str, str, bool]:
545
+ session = openai_register.create_session(self._proxy())
546
+ device_id = str(uuid.uuid4())
547
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
548
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
549
+ code_verifier, code_challenge = openai_register._generate_pkce()
550
+ params = {
551
+ "issuer": openai_register.auth_base,
552
+ "client_id": openai_register.platform_oauth_client_id,
553
+ "audience": openai_register.platform_oauth_audience,
554
+ "redirect_uri": openai_register.platform_oauth_redirect_uri,
555
+ "device_id": device_id,
556
+ "screen_hint": "login_or_signup",
557
+ "max_age": "0",
558
+ "login_hint": email,
559
+ "scope": "openid profile email offline_access",
560
+ "response_type": "code",
561
+ "response_mode": "query",
562
+ "state": secrets.token_urlsafe(32),
563
+ "nonce": secrets.token_urlsafe(32),
564
+ "code_challenge": code_challenge,
565
+ "code_challenge_method": "S256",
566
+ "auth0Client": openai_register.platform_auth0_client,
567
+ }
568
+
569
+ def _nav_headers(referer: str = "") -> dict[str, str]:
570
+ headers = dict(openai_register.navigate_headers)
571
+ if referer:
572
+ headers["referer"] = referer
573
+ return headers
574
+
575
+ def _json_headers(referer: str) -> dict[str, str]:
576
+ headers = dict(openai_register.common_headers)
577
+ headers["referer"] = referer
578
+ headers["oai-device-id"] = device_id
579
+ headers.update(openai_register._make_trace_headers())
580
+ return headers
581
+
582
+ def _authorize() -> tuple[Any, str]:
583
+ return openai_register.request_with_local_retry(
584
+ session,
585
+ "get",
586
+ f"{openai_register.auth_base}/api/accounts/authorize?{urlencode(params)}",
587
+ headers=_nav_headers(f"{openai_register.platform_base}/"),
588
+ allow_redirects=True,
589
+ verify=False,
590
+ )
591
+
592
+ def _authorize_continue() -> tuple[Any, str]:
593
+ headers = _json_headers(f"{openai_register.auth_base}/log-in?usernameKind=email")
594
+ headers["openai-sentinel-token"] = openai_register.build_sentinel_token(
595
+ session,
596
+ device_id,
597
+ "authorize_continue",
598
+ )
599
+ return openai_register.request_with_local_retry(
600
+ session,
601
+ "post",
602
+ f"{openai_register.auth_base}/api/accounts/authorize/continue",
603
+ json={"username": {"kind": "email", "value": email}},
604
+ headers=headers,
605
+ allow_redirects=False,
606
+ verify=False,
607
+ )
608
+
609
+ def _fail(prefix: str, resp: Any, error: str = "") -> None:
610
+ data = _response_json(resp) if resp is not None else {}
611
+ message = _error_message(data) or _response_excerpt(resp)
612
+ try:
613
+ session.close()
614
+ except Exception:
615
+ pass
616
+ raise RuntimeError(
617
+ f"{prefix}{': ' + message if message else ''}"
618
+ if message
619
+ else error or f"{prefix}: HTTP {getattr(resp, 'status_code', 'unknown')}"
620
+ )
621
+
622
+ resp, error = _authorize()
623
+ if resp is None:
624
+ _fail("新账号登录授权失败", resp, error)
625
+
626
+ resp, error = _authorize_continue()
627
+ if resp is not None and resp.status_code == 409:
628
+ for cookie in list(session.cookies):
629
+ if "auth.openai.com" in str(cookie.domain or ""):
630
+ session.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
631
+ session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
632
+ session.cookies.set("oai-did", device_id, domain="auth.openai.com")
633
+ resp, error = _authorize()
634
+ if resp is None:
635
+ _fail("新账号登录授权重试失败", resp, error)
636
+ resp, error = _authorize_continue()
637
+
638
+ if resp is None or resp.status_code != 200:
639
+ _fail("新账号邮箱提交失败", resp, error)
640
+
641
+ headers = _json_headers(f"{openai_register.auth_base}/log-in/password")
642
+ headers["openai-sentinel-token"] = openai_register.build_sentinel_token(
643
+ session,
644
+ device_id,
645
+ "password_verify",
646
+ )
647
+ resp, error = openai_register.request_with_local_retry(
648
+ session,
649
+ "post",
650
+ f"{openai_register.auth_base}/api/accounts/password/verify",
651
+ json={"password": password},
652
+ headers=headers,
653
+ allow_redirects=False,
654
+ verify=False,
655
+ )
656
+ if resp is None or resp.status_code != 200:
657
+ _fail("新账号密码校验失败", resp, error)
658
+
659
+ payload = _response_json(resp)
660
+ continue_url = _continue_url(payload)
661
+ page_type = _page_type(payload)
662
+ needs_email_code = _looks_like_email_otp(page_type, continue_url)
663
+ if needs_email_code:
664
+ self._send_login_otp(session)
665
+ return session, device_id, code_verifier, continue_url, needs_email_code
666
 
667
  def _fetch_chatgpt_session(self, session: Any) -> dict[str, Any]:
668
  headers = {
 
846
  first_name, last_name = openai_register._random_name()
847
  birthdate = openai_register._random_birthdate()
848
  display_name = f"{first_name} {last_name}"
849
+ signup_session = None
850
  try:
851
+ signup_session, signup_device_id, signup_code_verifier = self._start_signup_registration(normalized_email)
852
+ self._register_new_chatgpt_user(signup_session, signup_device_id, normalized_email, password)
853
+ self._send_signup_otp(signup_session)
854
  except RuntimeError as exc:
855
+ if signup_session is not None:
856
+ try:
857
+ signup_session.close()
858
+ except Exception:
859
+ pass
860
  raise RuntimeError(
861
  f"当前邮箱没有进入临时登录代码流程,且自动注册新账号失败:{exc}"
862
  ) from exc
863
  session_id = uuid.uuid4().hex[:12]
864
  pending = PendingEmailLogin(
865
  session_id=session_id,
866
+ session=signup_session,
867
  email=normalized_email,
868
+ device_id=signup_device_id,
869
+ code_verifier=signup_code_verifier,
870
  continue_url=continue_url,
871
  created_at=time.time(),
872
  updated_at=time.time(),
 
878
  with self._lock:
879
  self._prune_locked()
880
  self._pending[session_id] = pending
 
881
  return {
882
  "status": "pending_code",
883
  "session_id": session_id,
 
913
  resp, reason = openai_register.validate_otp(pending.session, pending.device_id, normalized_code)
914
  if resp is None or resp.status_code != 200:
915
  data = _response_json(resp) if resp is not None else {}
916
+ message = _error_message(data) or _response_excerpt(resp)
917
+ raise ValueError(reason or message or "验证码校验失败,请确认验证码是否最新")
918
  otp_payload = _response_json(resp)
919
  pending.continue_url = _continue_url(otp_payload) or pending.continue_url
920
  pending.otp_verified = True
921
  pending.updated_at = time.time()
922
 
923
  if pending.signup_password and not pending.signup_created:
924
+ profile_continue_url = self._create_signup_profile(
925
  pending.session,
926
  pending.device_id,
927
  pending.signup_name or "ChatGPT User",
928
  pending.signup_birthdate or openai_register._random_birthdate(),
929
  )
930
+ pending.continue_url = profile_continue_url or pending.continue_url
931
  pending.signup_created = True
932
  pending.updated_at = time.time()
933
 
934
+ if pending.signup_password and pending.signup_created and not pending.signup_login_started:
935
+ old_session = pending.session
936
+ (
937
+ login_session,
938
+ login_device_id,
939
+ login_code_verifier,
940
+ login_continue_url,
941
+ needs_email_code,
942
+ ) = self._start_signup_password_login(pending.email, pending.signup_password)
943
+ pending.session = login_session
944
+ pending.device_id = login_device_id
945
+ pending.code_verifier = login_code_verifier
946
+ pending.continue_url = login_continue_url or f"{openai_register.auth_base}/sign-in-with-chatgpt/codex/consent"
947
+ pending.signup_login_started = True
948
+ pending.otp_verified = not needs_email_code
949
+ pending.updated_at = time.time()
950
+ try:
951
+ old_session.close()
952
+ except Exception:
953
+ pass
954
+ if needs_email_code:
955
+ return {
956
+ "status": "pending_code",
957
+ "session_id": pending.session_id,
958
+ "email": pending.email,
959
+ "created_account": True,
960
+ "message": "新账号已创建,OpenAI 还需要再验证一次登录验证码。请输入邮箱新收到的验证码完成导入。",
961
+ }
962
+
963
  continue_url = pending.continue_url or f"{openai_register.auth_base}/sign-in-with-chatgpt/codex/consent"
964
  try:
965
+ if pending.signup_password:
966
+ tokens = self._exchange_signup_tokens(pending)
967
+ result = self._import_platform_token_account(tokens, pending.email, owner=pending.owner or owner)
968
+ else:
969
+ self._complete_chatgpt_login_session(pending.session, continue_url)
970
+ session_data = self._fetch_chatgpt_session(pending.session)
971
+ result = self._import_chatgpt_session_account(
972
+ session_data,
973
+ cookie_names=self._chatgpt_cookie_names(pending.session),
974
+ owner=pending.owner or owner,
975
+ )
976
  with self._lock:
977
  self._close_pending_locked(normalized_session_id)
978
  return {
test/test_account_image_capabilities.py CHANGED
@@ -905,7 +905,9 @@ class AuthServiceTests(unittest.TestCase):
905
 
906
  def test_email_login_import_can_register_new_email_with_manual_otp(self) -> None:
907
  service = AccountEmailLoginImportService()
908
- fake_session = _FakeSession()
 
 
909
 
910
  def fake_request_with_local_retry(_session, _method: str, url: str, **_kwargs):
911
  if "/api/accounts/authorize?" in url:
@@ -915,13 +917,18 @@ class AuthServiceTests(unittest.TestCase):
915
  raise AssertionError(f"unexpected request url: {url}")
916
 
917
  with (
918
- patch.object(email_login_module.openai_register, "create_session", return_value=fake_session),
919
  patch.object(service, "_start_chatgpt_nextauth_login", return_value="https://auth.openai.com/api/accounts/authorize?test=1"),
920
  patch.object(email_login_module.openai_register, "request_with_local_retry", side_effect=fake_request_with_local_retry),
921
  patch.object(email_login_module.openai_register, "build_sentinel_token", return_value="sentinel"),
922
  patch.object(email_login_module.openai_register, "_random_password", return_value="GeneratedPassword123."),
923
  patch.object(email_login_module.openai_register, "_random_name", return_value=("New", "User")),
924
  patch.object(email_login_module.openai_register, "_random_birthdate", return_value="1990-01-01"),
 
 
 
 
 
925
  patch.object(service, "_register_new_chatgpt_user") as register_user,
926
  patch.object(service, "_send_signup_otp") as send_signup_otp,
927
  patch.object(
@@ -929,13 +936,28 @@ class AuthServiceTests(unittest.TestCase):
929
  "validate_otp",
930
  return_value=(_FakeResponse(200, {"continue_url": "https://auth.openai.com/consent"}), ""),
931
  ),
932
- patch.object(service, "_create_signup_profile") as create_profile,
933
- patch.object(service, "_complete_chatgpt_login_session", return_value=None),
934
  patch.object(
935
  service,
936
- "_fetch_chatgpt_session",
937
- return_value={"user": {"email": "new@example.com"}, "accessToken": "access-token"},
938
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  patch.object(
940
  email_login_module.account_service,
941
  "add_account_items",
@@ -956,8 +978,9 @@ class AuthServiceTests(unittest.TestCase):
956
  self.assertEqual(started["status"], "pending_code")
957
  self.assertTrue(started["created_account"])
958
  pending = service._pending[started["session_id"]]
959
- register_user.assert_called_once_with(fake_session, pending.device_id, "new@example.com", "GeneratedPassword123.")
960
- send_signup_otp.assert_called_once_with(fake_session)
 
961
 
962
  completed = service.verify(str(started["session_id"]), "123456")
963
 
@@ -965,7 +988,140 @@ class AuthServiceTests(unittest.TestCase):
965
  self.assertTrue(completed["created_account"])
966
  self.assertEqual(completed["generated_password"], "GeneratedPassword123.")
967
  create_profile.assert_called_once()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
  self.assertEqual(len(service._pending), 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
 
970
  def test_email_login_import_returns_only_visible_accounts_for_owner(self) -> None:
971
  service = AccountEmailLoginImportService()
 
905
 
906
  def test_email_login_import_can_register_new_email_with_manual_otp(self) -> None:
907
  service = AccountEmailLoginImportService()
908
+ fake_login_session = _FakeSession()
909
+ fake_signup_session = _FakeSession()
910
+ fake_password_login_session = _FakeSession()
911
 
912
  def fake_request_with_local_retry(_session, _method: str, url: str, **_kwargs):
913
  if "/api/accounts/authorize?" in url:
 
917
  raise AssertionError(f"unexpected request url: {url}")
918
 
919
  with (
920
+ patch.object(email_login_module.openai_register, "create_session", return_value=fake_login_session),
921
  patch.object(service, "_start_chatgpt_nextauth_login", return_value="https://auth.openai.com/api/accounts/authorize?test=1"),
922
  patch.object(email_login_module.openai_register, "request_with_local_retry", side_effect=fake_request_with_local_retry),
923
  patch.object(email_login_module.openai_register, "build_sentinel_token", return_value="sentinel"),
924
  patch.object(email_login_module.openai_register, "_random_password", return_value="GeneratedPassword123."),
925
  patch.object(email_login_module.openai_register, "_random_name", return_value=("New", "User")),
926
  patch.object(email_login_module.openai_register, "_random_birthdate", return_value="1990-01-01"),
927
+ patch.object(
928
+ service,
929
+ "_start_signup_registration",
930
+ return_value=(fake_signup_session, "signup-device", "signup-verifier"),
931
+ ) as start_signup,
932
  patch.object(service, "_register_new_chatgpt_user") as register_user,
933
  patch.object(service, "_send_signup_otp") as send_signup_otp,
934
  patch.object(
 
936
  "validate_otp",
937
  return_value=(_FakeResponse(200, {"continue_url": "https://auth.openai.com/consent"}), ""),
938
  ),
939
+ patch.object(service, "_create_signup_profile", return_value="https://auth.openai.com/profile-consent") as create_profile,
 
940
  patch.object(
941
  service,
942
+ "_start_signup_password_login",
943
+ return_value=(
944
+ fake_password_login_session,
945
+ "login-device",
946
+ "login-verifier",
947
+ "https://auth.openai.com/login-consent",
948
+ False,
949
+ ),
950
+ ) as start_password_login,
951
+ patch.object(
952
+ service,
953
+ "_exchange_signup_tokens",
954
+ return_value={
955
+ "email": "new@example.com",
956
+ "access_token": "access-token",
957
+ "refresh_token": "refresh-token",
958
+ "id_token": "id-token",
959
+ },
960
+ ) as exchange_signup_tokens,
961
  patch.object(
962
  email_login_module.account_service,
963
  "add_account_items",
 
978
  self.assertEqual(started["status"], "pending_code")
979
  self.assertTrue(started["created_account"])
980
  pending = service._pending[started["session_id"]]
981
+ start_signup.assert_called_once_with("new@example.com")
982
+ register_user.assert_called_once_with(fake_signup_session, "signup-device", "new@example.com", "GeneratedPassword123.")
983
+ send_signup_otp.assert_called_once_with(fake_signup_session)
984
 
985
  completed = service.verify(str(started["session_id"]), "123456")
986
 
 
988
  self.assertTrue(completed["created_account"])
989
  self.assertEqual(completed["generated_password"], "GeneratedPassword123.")
990
  create_profile.assert_called_once()
991
+ start_password_login.assert_called_once_with("new@example.com", "GeneratedPassword123.")
992
+ exchange_signup_tokens.assert_called_once()
993
+ self.assertEqual(len(service._pending), 0)
994
+ self.assertTrue(fake_login_session.closed)
995
+ self.assertTrue(fake_signup_session.closed)
996
+ self.assertTrue(fake_password_login_session.closed)
997
+
998
+ def test_email_login_import_new_email_can_continue_second_login_otp(self) -> None:
999
+ service = AccountEmailLoginImportService()
1000
+ fake_login_session = _FakeSession()
1001
+ fake_signup_session = _FakeSession()
1002
+ fake_password_login_session = _FakeSession()
1003
+
1004
+ def fake_request_with_local_retry(_session, _method: str, url: str, **_kwargs):
1005
+ if "/api/accounts/authorize?" in url:
1006
+ return _FakeResponse(200, {}), ""
1007
+ if url.endswith("/api/accounts/authorize/continue"):
1008
+ return _FakeResponse(200, {"page": {"type": "login_password"}}), ""
1009
+ raise AssertionError(f"unexpected request url: {url}")
1010
+
1011
+ with (
1012
+ patch.object(email_login_module.openai_register, "create_session", return_value=fake_login_session),
1013
+ patch.object(service, "_start_chatgpt_nextauth_login", return_value="https://auth.openai.com/api/accounts/authorize?test=1"),
1014
+ patch.object(email_login_module.openai_register, "request_with_local_retry", side_effect=fake_request_with_local_retry),
1015
+ patch.object(email_login_module.openai_register, "build_sentinel_token", return_value="sentinel"),
1016
+ patch.object(email_login_module.openai_register, "_random_password", return_value="GeneratedPassword123."),
1017
+ patch.object(email_login_module.openai_register, "_random_name", return_value=("New", "User")),
1018
+ patch.object(email_login_module.openai_register, "_random_birthdate", return_value="1990-01-01"),
1019
+ patch.object(
1020
+ service,
1021
+ "_start_signup_registration",
1022
+ return_value=(fake_signup_session, "signup-device", "signup-verifier"),
1023
+ ),
1024
+ patch.object(service, "_register_new_chatgpt_user"),
1025
+ patch.object(service, "_send_signup_otp"),
1026
+ patch.object(
1027
+ email_login_module.openai_register,
1028
+ "validate_otp",
1029
+ side_effect=[
1030
+ (_FakeResponse(200, {"continue_url": "https://auth.openai.com/signup-consent"}), ""),
1031
+ (_FakeResponse(200, {"continue_url": "https://auth.openai.com/login-consent"}), ""),
1032
+ ],
1033
+ ) as validate_otp,
1034
+ patch.object(service, "_create_signup_profile", return_value="https://auth.openai.com/profile-consent"),
1035
+ patch.object(
1036
+ service,
1037
+ "_start_signup_password_login",
1038
+ return_value=(
1039
+ fake_password_login_session,
1040
+ "login-device",
1041
+ "login-verifier",
1042
+ "https://auth.openai.com/login-email-otp",
1043
+ True,
1044
+ ),
1045
+ ),
1046
+ patch.object(
1047
+ service,
1048
+ "_exchange_signup_tokens",
1049
+ return_value={
1050
+ "email": "new@example.com",
1051
+ "access_token": "access-token",
1052
+ "refresh_token": "refresh-token",
1053
+ "id_token": "id-token",
1054
+ },
1055
+ ) as exchange_signup_tokens,
1056
+ patch.object(
1057
+ email_login_module.account_service,
1058
+ "add_account_items",
1059
+ return_value={"added": 1, "skipped": 0, "items": [{"access_token": "access-token"}]},
1060
+ ),
1061
+ patch.object(
1062
+ email_login_module.account_service,
1063
+ "refresh_accounts",
1064
+ return_value={
1065
+ "refreshed": 1,
1066
+ "errors": [],
1067
+ "items": [{"access_token": "access-token", "email": "new@example.com"}],
1068
+ },
1069
+ ),
1070
+ ):
1071
+ started = service.start("new@example.com")
1072
+ second_code = service.verify(str(started["session_id"]), "111111")
1073
+
1074
+ self.assertEqual(second_code["status"], "pending_code")
1075
+ self.assertEqual(second_code["session_id"], started["session_id"])
1076
+ self.assertTrue(second_code["created_account"])
1077
+ exchange_signup_tokens.assert_not_called()
1078
+
1079
+ completed = service.verify(str(started["session_id"]), "222222")
1080
+
1081
+ self.assertEqual(completed["status"], "completed")
1082
+ self.assertEqual(validate_otp.call_count, 2)
1083
+ self.assertEqual(exchange_signup_tokens.call_count, 1)
1084
  self.assertEqual(len(service._pending), 0)
1085
+ self.assertTrue(fake_signup_session.closed)
1086
+ self.assertTrue(fake_password_login_session.closed)
1087
+
1088
+ def test_email_login_import_sends_second_login_otp_when_required(self) -> None:
1089
+ service = AccountEmailLoginImportService()
1090
+ fake_session = _FakeSession()
1091
+
1092
+ def fake_request_with_local_retry(_session, _method: str, url: str, **_kwargs):
1093
+ if "/api/accounts/authorize?" in url:
1094
+ return _FakeResponse(200, {}), ""
1095
+ if url.endswith("/api/accounts/authorize/continue"):
1096
+ return _FakeResponse(200, {}), ""
1097
+ if url.endswith("/api/accounts/password/verify"):
1098
+ return _FakeResponse(
1099
+ 200,
1100
+ {
1101
+ "continue_url": "https://auth.openai.com/email-verification",
1102
+ "page": {"type": "email_otp_verification"},
1103
+ },
1104
+ ), ""
1105
+ raise AssertionError(f"unexpected request url: {url}")
1106
+
1107
+ with (
1108
+ patch.object(email_login_module.openai_register, "create_session", return_value=fake_session),
1109
+ patch.object(email_login_module.openai_register, "_generate_pkce", return_value=("verifier", "challenge")),
1110
+ patch.object(email_login_module.openai_register, "build_sentinel_token", return_value="sentinel"),
1111
+ patch.object(email_login_module.openai_register, "request_with_local_retry", side_effect=fake_request_with_local_retry),
1112
+ patch.object(service, "_send_login_otp") as send_login_otp,
1113
+ ):
1114
+ session, device_id, code_verifier, continue_url, needs_email_code = service._start_signup_password_login(
1115
+ "new@example.com",
1116
+ "GeneratedPassword123.",
1117
+ )
1118
+
1119
+ self.assertIs(session, fake_session)
1120
+ self.assertTrue(device_id)
1121
+ self.assertEqual(code_verifier, "verifier")
1122
+ self.assertEqual(continue_url, "https://auth.openai.com/email-verification")
1123
+ self.assertTrue(needs_email_code)
1124
+ send_login_otp.assert_called_once_with(fake_session)
1125
 
1126
  def test_email_login_import_returns_only_visible_accounts_for_owner(self) -> None:
1127
  service = AccountEmailLoginImportService()
web/src/app/accounts/components/account-import-dialog.tsx CHANGED
@@ -334,9 +334,16 @@ export function AccountImportDialog({ disabled, isAdmin = false, onImported }: A
334
  }
335
 
336
  setIsSubmitting(true);
337
- setEmailLoginMessage("正在校验验证码,并自动读取 ChatGPT session...");
338
  try {
339
  const data = await verifyAccountEmailLoginImport(emailLoginSessionId, emailLoginCode);
 
 
 
 
 
 
 
340
  completeEmailLoginImport(data, data.message);
341
  } catch (error) {
342
  const message = error instanceof Error ? error.message : "验证码校验失败";
 
334
  }
335
 
336
  setIsSubmitting(true);
337
+ setEmailLoginMessage("正在校验验证码,并继续完成登录导入...");
338
  try {
339
  const data = await verifyAccountEmailLoginImport(emailLoginSessionId, emailLoginCode);
340
+ if (data.status === "pending_code" && data.session_id) {
341
+ setEmailLoginSessionId(data.session_id);
342
+ setEmailLoginCode("");
343
+ setEmailLoginMessage(data.message);
344
+ toast.success(data.message);
345
+ return;
346
+ }
347
  completeEmailLoginImport(data, data.message);
348
  } catch (error) {
349
  const message = error instanceof Error ? error.message : "验证码校验失败";