Files
OutlookRegister/controllers/oauth2.py
T
daimon d2db326c70 Initial public release of OutlookRegister.
Fork of LainsNL/OutlookRegister with OAuth hardening, optional recovery email,
batching, and MIT license. Ships example config only (no local secrets).
2026-07-23 20:47:55 +08:00

1330 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import time
from urllib.parse import parse_qs, quote, urlparse
import requests
# === OAuth2 常量 ===
CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753"
REDIRECT_URI = "https://localhost"
SCOPE = "https://graph.microsoft.com/.default offline_access"
AUTHORIZE_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
CONSENT_SELECTOR = '[data-testid="appConsentPrimaryButton"]'
EMAIL_SELECTOR = "#i0116"
EMAIL_NEXT_SELECTOR = "#idSIButton9"
PRIMARY_SELECTOR = '[data-testid="primaryButton"],input[data-testid="primaryButton"],input[type="submit"]'
# 个人帐户 HRD 选择(勿点工作/学校帐户)
MSA_TILE_SELECTOR = "#msaTile"
MSA_TILE_TITLE_SELECTOR = "#msaTileTitle"
PASSWORD_BYPASS_TEXTS = [
"使用密码",
"使用密码登录",
"Use password instead",
"Use your password",
"Sign in with a password",
]
PASSWORD_WRONG_TEXTS = [
"此密码不是你的 Microsoft 帐户的正确密码",
"This password is incorrect",
"你的帐户或密码不正确",
"帐户或密码不正确",
"账户或密码不正确",
"Your account or password is incorrect",
"incorrect account or password",
]
PASSWORD_BLOCKED_TEXTS = [
"密码登录不可用",
"请尝试其他方法",
"Password login is not available",
"Try another way",
"Try a different way",
"Sign-in method isn't available",
]
ACCOUNT_TYPE_HINT_TEXTS = [
"哪种类型的帐户",
"哪种类型的账户",
"which type of account",
"Work or school account",
"工作或学校帐户",
"工作或学校账户",
"个人帐户",
"个人账户",
"Personal account",
]
AUTH_NAV_TIMEOUT_MS = 45000
AUTH_ENTRY_TIMEOUT_MS = 45000
def build_auth_url(prefer_sso=True):
"""构造授权 URL。
prefer_sso=True(默认,COOKIE 路径):
- 不加 sso_reload,尽量用注册会话静默登录直接到 consent
prefer_sso=FalseNEW 冷启动):
- 可加 prompt=login 强制账密(一般仍不建议;默认也不加)
"""
params = {
'client_id': CLIENT_ID,
'response_type': 'code',
'redirect_uri': REDIRECT_URI,
'scope': SCOPE,
}
# 历史问题:sso_reload=true 会强制打断 cookie SSOCOOKIE 路径几乎必掉 #i0116
if not prefer_sso:
params['sso_reload'] = 'true'
return f"{AUTHORIZE_URL}?{'&'.join(f'{k}={quote(v)}' for k, v in params.items())}"
def _extract_code_from_url(url):
if 'localhost' not in url or 'code=' not in url:
return None
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
return query_params.get('code', [None])[0]
def _wait_for_code_capture(page, captured_code, timeout_ms=180000, poll_ms=250):
if captured_code[0]:
return captured_code[0]
code = _extract_code_from_url(page.url)
if code:
captured_code[0] = code
return code
try:
js_url = page.evaluate('window.location.href')
code = _extract_code_from_url(js_url)
if code:
captured_code[0] = code
return code
except Exception:
pass
deadline = time.time() + timeout_ms / 1000
while time.time() < deadline:
if captured_code[0]:
return captured_code[0]
code = _extract_code_from_url(page.url)
if code:
captured_code[0] = code
return code
try:
js_url = page.evaluate('window.location.href')
code = _extract_code_from_url(js_url)
if code:
captured_code[0] = code
return code
except Exception:
pass
page.wait_for_timeout(poll_ms)
return None
def _compact_exc(exc, max_len=180):
"""压缩 Playwright 异常,去掉多行 Call log,保持单行日志。"""
text = str(exc) if exc is not None else ""
if not text:
return ""
# 只保留第一行语义(如 Locator.click: Timeout 5000ms exceeded.
first = text.strip().splitlines()[0].strip()
# 去掉 Call log 及之后整段
for marker in ("Call log:", "\nCall log"):
idx = text.find(marker)
if idx >= 0:
first = text[:idx].strip().splitlines()[0].strip()
break
if len(first) > max_len:
first = first[: max_len - 3] + "..."
return first
def _wait_for_auth_state_or_code(page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, poll_ms=500, ignore_states=None):
ignore_states = set(ignore_states or ())
deadline = time.time() + timeout_ms / 1000
while time.time() < deadline:
if captured_code and _wait_for_code_capture(page, captured_code, timeout_ms=0):
return 'code'
state = _current_auth_entry_state(page)
if state != 'unknown' and state not in ignore_states:
return state
page.wait_for_timeout(poll_ms)
if captured_code and _wait_for_code_capture(page, captured_code, timeout_ms=0):
return 'code'
state = _current_auth_entry_state(page)
if state != 'unknown' and state not in ignore_states:
return state
return 'unknown'
def _locator_visible(locator):
try:
return locator.count() > 0 and locator.first.is_visible()
except Exception:
return False
def _text_exists(page, text):
try:
return page.get_by_text(text).count() > 0
except Exception:
return False
def _password_input(page):
"""密码框:中英 accessible name + 常见 id。"""
for name in ("密码", "Password", "password"):
try:
loc = page.get_by_role("textbox", name=name)
if _locator_visible(loc):
return loc
except Exception:
pass
for sel in ("#passwordEntry", "#i0118", 'input[type="password"]'):
loc = page.locator(sel)
if _locator_visible(loc):
return loc
return page.get_by_role("textbox", name="密码")
def _is_account_type_page(page):
"""个人/工作帐户选择页(HRD splitter)。"""
if _locator_visible(page.locator(MSA_TILE_SELECTOR)):
return True
if _locator_visible(page.locator(MSA_TILE_TITLE_SELECTOR)):
return True
# 文案兜底:同时出现个人 + 工作/学校 更稳
has_personal = (
_text_exists(page, "个人帐户")
or _text_exists(page, "个人账户")
or _text_exists(page, "Personal account")
)
has_work = (
_text_exists(page, "工作或学校帐户")
or _text_exists(page, "工作或学校账户")
or _text_exists(page, "Work or school account")
)
if has_personal and has_work:
return True
for t in ACCOUNT_TYPE_HINT_TEXTS:
if "哪种类型" in t or "which type" in t.lower():
if _text_exists(page, t):
return True
return False
def _is_protect_account_page(page):
"""「让我们来保护你的帐户」备用邮箱页。"""
try:
from controllers.recovery_bind import is_protect_account_page, is_ott_code_page
return is_protect_account_page(page) or is_ott_code_page(page)
except Exception:
if _locator_visible(page.locator("#EmailAddress")):
return True
if _locator_visible(page.locator("#iOttText")):
return True
return _text_exists(page, "保护你的帐户") or _text_exists(page, "保护您的帐户")
def _is_proof_verify_page(page):
"""冷登录:验证已绑定辅助邮箱 / 6 格验证码(不含仅 KMSI)。"""
try:
from controllers.recovery_bind import is_proof_confirm_page, is_code_entry_page
return is_proof_confirm_page(page) or is_code_entry_page(page)
except Exception:
if _locator_visible(page.locator("#proof-confirmation-email-input")):
return True
if _locator_visible(page.locator("#codeEntry-0")):
return True
return _text_exists(page, "验证你的电子邮件") or _text_exists(page, "输入你的代码")
def _is_kmsi_only_page(page):
try:
from controllers.recovery_bind import is_kmsi_page, is_proof_confirm_page, is_code_entry_page
return is_kmsi_page(page) and not is_proof_confirm_page(page) and not is_code_entry_page(page)
except Exception:
return _text_exists(page, "保持登录") or _text_exists(page, "Stay signed in")
def _current_auth_entry_state(page):
"""登录页状态机(可见 DOM 锚点,固定优先级)。
consent > account_type > protect_account > proof_verify > kmsi > login_email > login_password > unknown
"""
if _locator_visible(page.locator(CONSENT_SELECTOR)):
return 'consent'
if _is_account_type_page(page):
return 'account_type'
if _is_protect_account_page(page):
return 'protect_account'
if _is_proof_verify_page(page):
return 'proof_verify'
if _is_kmsi_only_page(page):
return 'kmsi'
if _locator_visible(page.locator(EMAIL_SELECTOR)):
return 'login_email'
if _locator_visible(_password_input(page)):
return 'login_password'
return 'unknown'
def _handle_kmsi(page, log):
"""保持登录状态?→ 点「否」secondaryButton。"""
try:
from controllers.recovery_bind import is_kmsi_page, _click_kmsi_no
if is_kmsi_page(page):
if _click_kmsi_no(page, log=log):
log('kmsi', '已点保持登录「否」', 'OK')
else:
log('kmsi', '点击「否」失败', 'WARN')
page.wait_for_timeout(800)
except Exception as exc:
log('kmsi', f'处理异常: {_compact_exc(exc)}', 'WARN')
return _current_auth_entry_state(page)
def _handle_proof_verify(page, log, temp_mail_cfg=None, recovery_session=None, failure_hook=None):
"""OAuth 冷登录:验证已绑定辅助邮箱(发码 → #codeEntry-0..5 自动提交 → KMSI 否)。"""
# 仅 KMSI 时不需要 jwt
if _is_kmsi_only_page(page):
return _handle_kmsi(page, log)
log('proof_verify', '检测到「验证电子邮件/输入代码」页', 'WARN')
session = recovery_session
if not session or not session.get('address') or not session.get('jwt'):
log('proof_verify', '无注册阶段保存的辅助邮箱 jwt,无法接码', 'FAIL')
if failure_hook:
try:
failure_hook('recovery_bind_fail')
except Exception:
pass
return _current_auth_entry_state(page)
try:
from controllers.recovery_bind import verify_bound_email_on_login
ok = verify_bound_email_on_login(
page, session, temp_mail_cfg or {}, log=log,
)
except Exception as exc:
log('proof_verify', f'验证异常: {_compact_exc(exc)}', 'FAIL')
ok = False
if ok:
log('proof_verify', '辅助邮箱验证流程完成', 'OK')
else:
if failure_hook:
try:
failure_hook('recovery_bind_fail')
except Exception:
pass
log('proof_verify', '辅助邮箱验证失败', 'FAIL')
page.wait_for_timeout(500)
return _current_auth_entry_state(page)
def _handle_protect_account(page, log, temp_mail_cfg=None, failure_hook=None, already_bound=False):
"""OAuth 中的保护帐户页(兜底,非 100% 出现)。
主路径应在「注册成功 → mail/0 前」完成绑定;此处仅当注册时跳过/失败后才常见。
already_bound=True:注册阶段已绑成功,优先点跳过离开,避免重复绑定。
"""
if already_bound:
log('protect_account', '注册阶段已绑定过,OAuth 侧优先离开此页', 'INFO')
try:
if _locator_visible(page.locator('#iShowSkip')):
page.locator('#iShowSkip').first.click(timeout=4000)
page.wait_for_timeout(800)
except Exception:
pass
return _current_auth_entry_state(page)
log('protect_account', 'OAuth 出现保护帐户页(概率事件),尝试绑定', 'WARN')
cfg = temp_mail_cfg or {}
ok = False
if cfg.get('enabled', True):
try:
from controllers.recovery_bind import bind_recovery_email
result = bind_recovery_email(page, cfg, log=log)
if isinstance(result, tuple):
ok = bool(result[0])
else:
ok = bool(result)
except Exception as exc:
log('protect_account', f'绑定异常: {exc}', 'FAIL')
ok = False
if ok:
log('protect_account', 'OAuth 阶段备用邮箱绑定成功', 'OK')
page.wait_for_timeout(800)
try:
if _locator_visible(page.locator('#iShowSkip')):
page.locator('#iShowSkip').first.click(timeout=2500)
log('protect_account', '绑定后仍有跳过链,已点击', 'INFO')
except Exception:
pass
else:
if failure_hook:
try:
failure_hook('recovery_bind_fail')
except Exception:
pass
try:
if _locator_visible(page.locator('#iShowSkip')):
page.locator('#iShowSkip').first.click(timeout=4000)
log('protect_account', 'OAuth 绑定失败,已 #iShowSkip', 'WARN')
page.wait_for_timeout(800)
except Exception as exc:
log('protect_account', f'跳过失败: {exc}', 'WARN')
page.wait_for_timeout(500)
st = _current_auth_entry_state(page)
if ok and st == 'protect_account':
try:
if not _locator_visible(page.locator('#EmailAddress')) and not _locator_visible(page.locator('#iOttText')):
return 'unknown'
except Exception:
pass
return st
def _dump_auth_page(page, log, stage='auth_dump'):
"""失败时记录 URL + 正文摘要,便于对照截图。"""
try:
url = page.url or ''
except Exception:
url = ''
body = ''
try:
body = (page.locator('body').inner_text(timeout=800) or '')[:240].replace('\n', ' ')
except Exception:
body = ''
state = _current_auth_entry_state(page)
log(stage, f"state={state} url={url[:180]} body={body!r}", 'WARN')
return state
def _click_personal_account(page, log=None):
"""点 HRD「个人帐户」#msaTile(禁止点工作/学校)。"""
clicked = False
# 1) 标准 msa tile
try:
tile = page.locator(MSA_TILE_SELECTOR)
if _locator_visible(tile):
tile.first.click(timeout=5000)
clicked = True
if log:
log('account_type', '已点击 #msaTile 个人帐户', 'OK')
except Exception as exc:
if log:
log('account_type', f'#msaTile 点击失败: {exc}', 'WARN')
# 2) 标题区域
if not clicked:
try:
title = page.locator(MSA_TILE_TITLE_SELECTOR)
if _locator_visible(title):
title.first.click(timeout=5000)
clicked = True
if log:
log('account_type', '已点击 #msaTileTitle', 'OK')
except Exception:
pass
# 3) 文案 role=button / 文本
if not clicked:
for text in ("个人帐户", "个人账户", "Personal account"):
try:
btn = page.get_by_role("button", name=text)
if _locator_visible(btn):
btn.first.click(timeout=5000)
clicked = True
if log:
log('account_type', f'已点击 button:{text}', 'OK')
break
except Exception:
pass
try:
loc = page.get_by_text(text, exact=False)
if _locator_visible(loc):
# 避免点到「重命名你的个人 Microsoft 帐户」链接:优先含 display 的 tile
loc.first.click(timeout=5000)
clicked = True
if log:
log('account_type', f'已点击 text:{text}', 'OK')
break
except Exception:
pass
if clicked:
try:
page.wait_for_timeout(1200)
except Exception:
pass
elif log:
log('account_type', '未找到可点击的个人帐户入口', 'WARN')
return clicked
def _resolve_account_type(page, log, captured_code=None, max_rounds=3):
"""若在帐户类型页,点击个人帐户并返回新状态。"""
state = _current_auth_entry_state(page)
for _ in range(max_rounds):
if state != 'account_type':
return state
log('account_type', '检测到个人/工作帐户选择页,点击个人帐户', 'WARN')
if not _click_personal_account(page, log):
_dump_auth_page(page, log, 'account_type_dump')
return 'account_type'
try:
_settle_auth_page(page, log, 'account_type')
except Exception:
page.wait_for_timeout(800)
state = _wait_for_auth_state_or_code(
page,
captured_code,
timeout_ms=15000,
ignore_states=set(),
)
# 点完仍可能短暂 unknown
if state == 'unknown':
state = _current_auth_entry_state(page)
return state
def _wait_for_auth_entry_state(page, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, poll_ms=500, ignore_states=None):
return _wait_for_auth_state_or_code(page, None, timeout_ms=timeout_ms, poll_ms=poll_ms, ignore_states=ignore_states)
def _settle_auth_page(page, log, stage, timeout_ms=AUTH_NAV_TIMEOUT_MS):
try:
page.wait_for_load_state('domcontentloaded', timeout=timeout_ms)
except Exception as e:
log(stage, f'等待 domcontentloaded 超时: {e}', 'WARN')
try:
page.wait_for_load_state('load', timeout=timeout_ms)
except Exception as e:
log(stage, f'等待 load 超时,继续检测入口: {e}', 'WARN')
page.wait_for_timeout(1200)
def _disable_auth_page_autofill(page, log=None):
try:
page.evaluate(
"""() => {
document.querySelectorAll('input').forEach((el) => {
try {
el.setAttribute('autocomplete', 'off');
el.setAttribute('autocapitalize', 'off');
el.setAttribute('autocorrect', 'off');
el.setAttribute('spellcheck', 'false');
el.setAttribute('data-lpignore', 'true');
} catch (e) {}
});
}"""
)
if log:
log('autofill', '已尝试关闭页面输入框自动填充提示', 'INFO')
except Exception as exc:
if log:
log('autofill', f'关闭页面自动填充提示失败: {exc}', 'WARN')
def _submit_email_fill(page, full_email):
_disable_auth_page_autofill(page)
locator = page.locator(EMAIL_SELECTOR).first
locator.click(timeout=5000)
page.keyboard.press("Escape")
page.wait_for_timeout(150)
locator.fill("")
page.wait_for_timeout(100)
locator.fill(full_email, timeout=5000)
page.wait_for_timeout(300)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000)
def _submit_email_type(page, full_email):
_disable_auth_page_autofill(page)
locator = page.locator(EMAIL_SELECTOR).first
locator.click(timeout=5000)
page.keyboard.press("Control+A")
page.keyboard.press("Backspace")
page.wait_for_timeout(100)
locator.type(full_email, delay=35, timeout=10000)
page.wait_for_timeout(250)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000)
def _submit_email_js_exact(page, full_email):
page.wait_for_selector(EMAIL_SELECTOR, state="visible", timeout=10000)
_disable_auth_page_autofill(page)
page.eval_on_selector(
EMAIL_SELECTOR,
"""(el, value) => {
el.focus();
el.setAttribute('autocomplete', 'off');
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
nativeInputValueSetter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
}""",
full_email,
)
page.wait_for_timeout(500)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
page.locator(EMAIL_NEXT_SELECTOR).click(timeout=5000)
def _submit_email(page, full_email, log):
page.wait_for_selector(EMAIL_SELECTOR, state="visible", timeout=10000)
methods = [
("fill", _submit_email_fill),
("type", _submit_email_type),
("js_exact", _submit_email_js_exact),
("js_exact_retry", _submit_email_js_exact),
]
success_states = ('login_password', 'consent', 'code', 'account_type', 'protect_account')
last_error = None
last_stage = 'unknown'
for name, method in methods:
try:
# 提交过程中可能已跳到帐户类型/密码/保护帐户
cur = _current_auth_entry_state(page)
if cur in success_states:
if cur == 'account_type':
cur = _resolve_account_type(page, log)
log('oauth_email', f"提交前已在阶段={cur}", 'OK')
return cur
if page.locator(EMAIL_SELECTOR).count() == 0:
cur = _current_auth_entry_state(page)
if cur == 'account_type':
cur = _resolve_account_type(page, log)
return cur
current = page.eval_on_selector(EMAIL_SELECTOR, "(el) => (el.value || '').trim()")
log('oauth_email', f"尝试 {name},提交前值={current!r}", 'INFO')
method(page, full_email)
stage = _wait_for_auth_entry_state(page, timeout_ms=12000)
last_stage = stage
if stage == 'account_type':
stage = _resolve_account_type(page, log)
last_stage = stage
if stage in ('login_password', 'consent', 'code', 'protect_account'):
log('oauth_email', f"{name} 成功进入阶段={stage}", 'OK')
return stage
still_here = page.locator(EMAIL_SELECTOR).count() > 0 and page.locator(EMAIL_SELECTOR).first.is_visible()
err = ""
if still_here:
err = page.eval_on_selector("#usernameError", "(el) => (el.innerText || '').trim()") if page.locator("#usernameError").count() > 0 else ""
log('oauth_email', f"{name} 后仍未进入下一阶段 stage={stage} error={err!r}", 'WARN')
except Exception as exc:
last_error = exc
brief = _compact_exc(exc)
log('oauth_email', f"{name} 失败: {brief}", 'WARN')
# type 时常见:Next 点击超时但页面已导航到密码/帐户类型/同意页
stage = _current_auth_entry_state(page)
if stage == 'account_type':
stage = _resolve_account_type(page, log)
last_stage = stage
if stage in ('login_password', 'consent', 'code', 'protect_account'):
log('oauth_email', f"{name} 异常后已在阶段={stage}", 'OK')
return stage
if last_stage in ('login_password', 'consent', 'code', 'account_type', 'protect_account'):
if last_stage == 'account_type':
last_stage = _resolve_account_type(page, log)
return last_stage
if last_error:
raise RuntimeError(f"邮箱提交失败: {_compact_exc(last_error)}")
raise RuntimeError("邮箱提交后未进入密码页")
def _click_use_password(page):
for text in PASSWORD_BYPASS_TEXTS:
try:
btn = page.get_by_role("button", name=text)
if btn.count() > 0 and btn.first.is_visible():
btn.first.click(timeout=5000)
page.wait_for_timeout(1500)
return
except Exception:
pass
try:
btn = page.get_by_text(text)
if btn.count() > 0 and btn.first.is_visible():
btn.first.click(timeout=5000)
page.wait_for_timeout(1500)
return
except Exception:
pass
def _describe_password_candidates(page):
parts = []
for selector in ('#passwordEntry', '#i0118', 'input[type="password"]'):
try:
locator = page.locator(selector)
count = locator.count()
rows = []
for idx in range(count):
item = locator.nth(idx)
try:
visible = item.is_visible()
except Exception as exc:
visible = f"err:{exc.__class__.__name__}"
try:
meta = item.evaluate(
"""(el) => ({
id: el.id || '',
name: el.name || '',
type: el.getAttribute('type') || '',
tabindex: el.getAttribute('tabindex') || '',
ariaHidden: el.getAttribute('aria-hidden') || '',
readonly: el.hasAttribute('readonly'),
disabled: !!el.disabled
})"""
)
except Exception:
meta = {}
rows.append(f"{idx}:visible={visible},meta={meta}")
parts.append(f"{selector} count={count} [{' ; '.join(rows)}]")
except Exception:
parts.append(f"{selector} error")
return " | ".join(parts)
def _password_locator(page, log, timeout_ms=15000):
deadline = time.time() + timeout_ms / 1000
last_snapshot = ""
while time.time() < deadline:
_click_use_password(page)
for selector in ('#passwordEntry', '#i0118', 'input[type="password"]'):
try:
locator = page.locator(selector)
count = locator.count()
except Exception:
continue
for idx in range(count):
item = locator.nth(idx)
try:
if item.is_visible():
log('oauth_password', f"使用密码框 {selector}[{idx}]", 'INFO')
return item, f"{selector}[{idx}]"
except Exception:
continue
last_snapshot = _describe_password_candidates(page)
page.wait_for_timeout(300)
raise RuntimeError(f"未找到可见密码框:{last_snapshot}")
def _submit_password(page, password, log):
_click_use_password(page)
_disable_auth_page_autofill(page)
log('oauth_password', f"密码候选快照:{_describe_password_candidates(page)}", 'INFO')
locator, locator_name = _password_locator(page, log=log, timeout_ms=15000)
locator.evaluate(
"""(el, value) => {
el.focus();
el.removeAttribute('readonly');
el.removeAttribute('aria-hidden');
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}""",
password,
)
page.wait_for_timeout(200)
try:
filled_len = locator.evaluate("(el) => (el.value || '').length")
log('oauth_password', f"{locator_name} 已写入密码,长度={filled_len}", 'INFO')
except Exception:
log('oauth_password', f"{locator_name} 已写入密码", 'INFO')
page.wait_for_timeout(400)
try:
page.get_by_test_id("primaryButton").click(timeout=5000)
log('oauth_password', "点击 data-testid=primaryButton 提交密码", 'INFO')
except Exception:
page.keyboard.press("Enter")
log('oauth_password', "主按钮点击失败,改用 Enter 提交密码", 'WARN')
def _has_invalid_password(page):
for t in PASSWORD_WRONG_TEXTS:
if _text_exists(page, t):
return True
return False
def _has_password_login_blocked(page):
for t in PASSWORD_BLOCKED_TEXTS:
if _text_exists(page, t):
return True
return False
def _has_unknown_account(page):
return (
_text_exists(page, '找不到使用该用户名的帐户')
or _text_exists(page, '找不到使用该用户名的账户')
or _text_exists(page, "We couldn't find an account with that username")
or _text_exists(page, "That Microsoft account doesn't exist")
or _locator_visible(page.locator('#usernameError'))
)
def _dismiss_passkey_setup(page, log=None):
"""密码后可能跳到「正在设置密钥」/ fido create,尝试取消回到同意流。"""
try:
url = page.url or ''
except Exception:
url = ''
body_hint = False
try:
body_hint = (
_text_exists(page, '正在设置密钥')
or _text_exists(page, '安全窗口')
or _text_exists(page, 'passkey')
or _text_exists(page, '通行密钥')
or 'fido/create' in url
)
except Exception:
pass
if not body_hint and 'fido' not in url:
return False
if log:
log('passkey', f'检测到密钥设置页 url={url[:120]}', 'WARN')
for text in ('取消', 'Cancel', '以后再说', 'Not now', '暂时跳过', 'Skip'):
try:
if _locator_visible(page.get_by_role('button', name=text)):
page.get_by_role('button', name=text).first.click(timeout=3000)
page.wait_for_timeout(1000)
if log:
log('passkey', f'已点击 {text}', 'OK')
return True
except Exception:
pass
try:
loc = page.locator(f'input[type="button"][value="{text}"]')
if _locator_visible(loc):
loc.first.click(timeout=3000)
page.wait_for_timeout(1000)
if log:
log('passkey', f'已点击 input {text}', 'OK')
return True
except Exception:
pass
# 最后:若仍在 fido 页,直接跳回我们的 authorize(依赖 cookie
try:
page.goto(build_auth_url(prefer_sso=True), timeout=AUTH_NAV_TIMEOUT_MS, wait_until='domcontentloaded')
page.wait_for_timeout(1200)
if log:
log('passkey', '密钥页无法取消,已回跳 authorize', 'WARN')
return True
except Exception:
return False
def _run_cookie_recovery(page, auth_url, log, entry_timeout_ms=AUTH_ENTRY_TIMEOUT_MS):
last_state = 'unknown'
for method_name, action in [
('reload', lambda: page.reload(wait_until="domcontentloaded", timeout=AUTH_NAV_TIMEOUT_MS)),
('location.reload', lambda: page.evaluate("() => location.reload()")),
('goto', lambda: page.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded")),
]:
log('cookie_recovery', f'执行 {method_name}', 'WARN')
try:
if method_name == 'location.reload':
with page.expect_navigation(wait_until="domcontentloaded", timeout=AUTH_NAV_TIMEOUT_MS):
action()
else:
action()
except Exception as e:
log('cookie_recovery', f'{method_name} 失败: {e}', 'WARN')
continue
_settle_auth_page(page, log, 'cookie_recovery')
_disable_auth_page_autofill(page, log)
state = _wait_for_auth_entry_state(page, timeout_ms=entry_timeout_ms)
if state == 'account_type':
state = _resolve_account_type(page, log)
last_state = state
log('cookie_recovery', f'{method_name} 后状态={state}', 'INFO')
if state in ('consent', 'login_password', 'code'):
return state
if state == 'login_email':
continue
if state == 'account_type':
# 已尝试点击个人帐户仍停在选择页
continue
if method_name == 'goto':
return state
return 'login_email' if last_state == 'login_email' else last_state
def _digest_post_email_states(
page, log, state, captured_code=None, temp_mail_cfg=None,
recovery_already_bound=False, recovery_session=None, failure_hook=None, rounds=4,
):
"""邮箱提交后可能出现的中间页:帐户类型 / 绑定保护 / 验证辅助邮箱 / 密钥 / KMSI。"""
for _ in range(rounds):
if state == 'account_type':
state = _resolve_account_type(page, log, captured_code=captured_code)
log('account_type', f'处理后状态={state}', 'INFO')
continue
if state == 'protect_account':
state = _handle_protect_account(
page, log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
log('protect_account', f'处理后状态={state}', 'INFO')
continue
if state == 'proof_verify':
state = _handle_proof_verify(
page, log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
log('proof_verify', f'处理后状态={state}', 'INFO')
continue
if state == 'kmsi':
state = _handle_kmsi(page, log)
log('kmsi', f'处理后状态={state}', 'INFO')
continue
if state == 'unknown':
if _dismiss_passkey_setup(page, log):
state = _wait_for_auth_state_or_code(page, captured_code, timeout_ms=12000)
continue
# KMSI 可能落在 unknown
try:
from controllers.recovery_bind import is_kmsi_page, _click_kmsi_no
if is_kmsi_page(page):
_click_kmsi_no(page, log=log)
state = _wait_for_auth_state_or_code(page, captured_code, timeout_ms=8000)
continue
except Exception:
pass
break
return state
def _perform_login_after_cookie_fail(
page, full_email, password, log, failure_hook=None, state='login_email',
captured_code=None, temp_mail_cfg=None, recovery_already_bound=False, recovery_session=None,
):
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=4,
)
if state == 'login_email':
log('login_email', '开始输入邮箱', 'WARN')
try:
email_stage = _submit_email(page, full_email, log)
except Exception as exc:
log('login_email', f'邮箱提交异常: {_compact_exc(exc)}', 'WARN')
email_stage = _current_auth_entry_state(page)
if email_stage in (
'login_password', 'consent', 'code', 'account_type',
'protect_account', 'proof_verify', 'kmsi',
):
state = email_stage
else:
state = _wait_for_auth_state_or_code(
page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_email'}
)
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=4,
)
log('login_email', f'邮箱提交后状态={state}', 'INFO')
if _has_unknown_account(page):
_dump_auth_page(page, log)
log('login_email', '邮箱不存在', 'FAIL')
return False
if state == 'login_email':
if failure_hook:
failure_hook('oauth_login_timeout')
_dump_auth_page(page, log)
log('login_email', '邮箱页停留超时', 'FAIL')
return False
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=3,
)
if state == 'login_password':
# 冷登录验证辅助邮箱后,有时不必再输密码;若出现密码页再填
if _has_password_login_blocked(page):
if failure_hook:
failure_hook('oauth_password_blocked')
_dump_auth_page(page, log)
log('login_password', '密码登录不可用,跳过硬填', 'FAIL')
return False
log('login_password', '开始输入密码', 'WARN')
_submit_password(page, password, log)
if _has_password_login_blocked(page):
if failure_hook:
failure_hook('oauth_password_blocked')
_dump_auth_page(page, log)
log('login_password', '检测到密码登录不可用', 'FAIL')
return False
if _has_invalid_password(page):
if failure_hook:
failure_hook('oauth_password_wrong')
_dump_auth_page(page, log)
log('login_password', '检测到密码错误提示', 'FAIL')
return False
state = _wait_for_auth_state_or_code(
page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_password'}
)
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=4,
)
log('login_password', f'密码提交后状态={state}', 'INFO')
if state == 'code':
return True
if state != 'consent':
if _has_password_login_blocked(page):
if failure_hook:
failure_hook('oauth_password_blocked')
_dump_auth_page(page, log)
log('login_password', '密码提交后:密码登录不可用', 'FAIL')
elif _has_invalid_password(page):
if failure_hook:
failure_hook('oauth_password_wrong')
_dump_auth_page(page, log)
log('login_password', '检测到密码错误提示', 'FAIL')
else:
if failure_hook:
failure_hook('oauth_consent_fail')
_dump_auth_page(page, log)
log('login_password', f'未进入同意页面 final_state={state}', 'FAIL')
return False
# 冷登录常见:邮箱 → proof(codeEntry 自动验证) → kmsi 否 → consent(可能无密码页)
if state in ('proof_verify', 'kmsi'):
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=4,
)
log('proof_verify', f'proof/kmsi 处理后状态={state}', 'INFO')
if state == 'login_password':
# 验证后若仍要密码,再走一轮
if not _has_password_login_blocked(page):
log('login_password', 'proof 后出现密码页,继续填写', 'WARN')
_submit_password(page, password, log)
state = _wait_for_auth_state_or_code(
page, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS, ignore_states={'login_password'}
)
state = _digest_post_email_states(
page, log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=3,
)
if state == 'code':
return True
if state == 'consent':
return True
if state not in ('consent', 'code'):
if failure_hook:
failure_hook('oauth_consent_fail')
_dump_auth_page(page, log)
log('proof_verify', f'验证后未进入同意页 final_state={state}', 'FAIL')
return False
return state in ('consent', 'code')
def _exchange_code_once(code, proxy_url=None, timeout_sec=20):
proxies = None
if proxy_url:
proxies = {"http": proxy_url, "https": proxy_url}
response = requests.post(
TOKEN_URL,
data={
'client_id': CLIENT_ID,
'code': code,
'redirect_uri': REDIRECT_URI,
'grant_type': 'authorization_code',
'scope': SCOPE,
},
headers={'Content-Type': 'application/x-www-form-urlencoded'},
timeout=timeout_sec,
proxies=proxies,
)
response.raise_for_status()
return response.json()
def _exchange_code_with_retry(code, log, failure_hook=None, current_proxy="", token_proxy_getter=None):
proxy_candidates = []
saw_network_error = False
for item in (current_proxy,):
if item and item not in proxy_candidates:
proxy_candidates.append(item)
if token_proxy_getter:
for _ in range(2):
try:
picked = token_proxy_getter(exclude=proxy_candidates[-1] if proxy_candidates else current_proxy)
except TypeError:
picked = token_proxy_getter()
except Exception as exc:
log('token', f'获取新代理失败: {exc}', 'WARN')
picked = ""
if picked and picked not in proxy_candidates:
proxy_candidates.append(picked)
proxy_candidates.append("")
total_attempts = len(proxy_candidates)
last_error = None
for idx in range(total_attempts):
proxy_url = proxy_candidates[idx]
proxy_text = proxy_url or "direct"
try:
log('token', f'开始换 token 第 {idx + 1}/{total_attempts} 次 proxy={proxy_text}', 'INFO')
data = _exchange_code_once(code, proxy_url=proxy_url or None, timeout_sec=20)
if 'refresh_token' not in data:
last_error = RuntimeError(data.get('error_description') or data.get('error') or 'unknown')
log('token', f"token请求失败 proxy={proxy_text}: {data.get('error', 'unknown')}", 'WARN')
if idx < total_attempts - 1:
time.sleep(1.5 + idx)
continue
break
return True, data['refresh_token']
except (requests.exceptions.SSLError, requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
last_error = exc
saw_network_error = True
log('token', f'网络异常 proxy={proxy_text}: {exc}', 'WARN')
if idx < total_attempts - 1:
time.sleep(1.5 + idx)
continue
except Exception as exc:
last_error = exc
log('token', f'换 token 异常 proxy={proxy_text}: {exc}', 'WARN')
if idx < total_attempts - 1:
time.sleep(1.5 + idx)
continue
if saw_network_error and failure_hook:
failure_hook('oauth_token_network_fail')
if failure_hook:
failure_hook('oauth_token_fail')
log('token', f'最终换 token 失败: {last_error}', 'FAIL')
return False, None
def _click_consent_and_exchange(page, captured_code, log, failure_hook=None, current_proxy="", token_proxy_getter=None):
accept_btn = page.locator(CONSENT_SELECTOR)
accept_btn.wait_for(state='visible', timeout=60000)
accept_btn.click(timeout=10000)
log('consent', '点击接受授权', 'OK')
code = _wait_for_code_capture(page, captured_code, timeout_ms=180000)
if not code:
if failure_hook:
failure_hook('oauth_code_fail')
log('callback', '3分钟内未捕获到code', 'FAIL')
return False, None
log('callback', '捕获到code', 'OK')
return _exchange_code_with_retry(
code,
log=log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
def _exchange_captured_code(page, captured_code, log, failure_hook=None, current_proxy="", token_proxy_getter=None):
code = _wait_for_code_capture(page, captured_code, timeout_ms=1000, poll_ms=100)
if not code:
return False, None
log('callback', '已直接捕获到code,跳过同意页', 'OK')
return _exchange_code_with_retry(
code,
log=log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
def get_oauth2_token(page, full_email, password, results_dir=None, prefix='', backup_proxy=None, failure_hook=None, log_hook=None, current_proxy="", token_proxy_getter=None, temp_mail_cfg=None, recovery_already_bound=False, recovery_session=None):
# 同 context 必须 prefer_sso:不要 sso_reload,否则 cookie 会话被强制打断
auth_url = build_auth_url(prefer_sso=True)
def _log(stage, message, level='INFO'):
if log_hook:
log_hook(stage, message, level)
return
tag = prefix if prefix else "[OAuth2:COOKIE]"
print(f"{tag}[{level}] {time.strftime('%H:%M:%S')} | {stage} | {message}")
def _try_flow():
_log('start', '开始 OAuth2 (同浏览器 context 复用 cookie,无 sso_reload)')
# 同一 BrowserContext 新开 tab,共享注册后的 login.live.com cookie
pg = page.context.new_page()
captured_code = [None]
def on_request(request):
code = _extract_code_from_url(request.url)
if code:
captured_code[0] = code
def on_frame_navigated(frame):
code = _extract_code_from_url(frame.url)
if code:
captured_code[0] = code
pg.on('request', on_request)
pg.on('framenavigated', on_frame_navigated)
try:
t1 = time.time()
pg.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded")
_settle_auth_page(pg, _log, 'goto')
_disable_auth_page_autofill(pg, _log)
_log('goto', f"进入auth页面 (+{time.time()-t1:.0f}s)")
# SSO 有时慢,多给一点时间再判状态
state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=AUTH_ENTRY_TIMEOUT_MS)
_log('entry', f'首次检测状态={state}')
if state == 'account_type':
state = _resolve_account_type(pg, _log, captured_code=captured_code)
_log('entry', f'帐户类型处理后状态={state}')
if state == 'protect_account':
state = _handle_protect_account(
pg, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
_log('entry', f'保护帐户处理后状态={state}')
if state == 'proof_verify':
state = _handle_proof_verify(
pg, _log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
_log('entry', f'proof 验证后状态={state}')
if state == 'kmsi':
state = _handle_kmsi(pg, _log)
_log('entry', f'kmsi 处理后状态={state}')
# 策略:有 cookie 的环境下,login_email 时**不要**连环 reload(会冲掉 SSO)。
if state == 'login_email':
_log('entry', '仍需邮箱:跳过连环 recovery,同页直接补登(保留 cookie', 'WARN')
elif state == 'unknown':
_dump_auth_page(pg, _log, 'entry_unknown')
if _is_account_type_page(pg):
state = _resolve_account_type(pg, _log, captured_code=captured_code)
elif _is_protect_account_page(pg):
state = _handle_protect_account(
pg, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
elif _is_proof_verify_page(pg):
state = _handle_proof_verify(
pg, _log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
elif _is_kmsi_only_page(pg):
state = _handle_kmsi(pg, _log)
else:
_log('entry', 'unknown:单次 goto 重试 authorize', 'WARN')
try:
pg.goto(auth_url, timeout=AUTH_NAV_TIMEOUT_MS, wait_until="domcontentloaded")
_settle_auth_page(pg, _log, 'goto_retry')
state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=15000)
except Exception as exc:
_log('entry', f'goto 重试失败: {_compact_exc(exc)}', 'WARN')
_log('entry', f'处理后状态={state}')
if state == 'account_type':
state = _resolve_account_type(pg, _log, captured_code=captured_code)
if state == 'protect_account':
state = _handle_protect_account(
pg, _log, temp_mail_cfg=temp_mail_cfg, failure_hook=failure_hook,
already_bound=recovery_already_bound,
)
if state == 'proof_verify':
state = _handle_proof_verify(
pg, _log, temp_mail_cfg=temp_mail_cfg,
recovery_session=recovery_session, failure_hook=failure_hook,
)
if state == 'kmsi':
state = _handle_kmsi(pg, _log)
if state in ('login_email', 'login_password', 'account_type', 'protect_account', 'proof_verify', 'kmsi'):
ok = _perform_login_after_cookie_fail(
pg,
full_email,
password,
_log,
failure_hook=failure_hook,
state=state,
captured_code=captured_code,
temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound,
recovery_session=recovery_session,
)
if not ok:
return False, None
state = _wait_for_auth_state_or_code(pg, captured_code, timeout_ms=8000)
state = _digest_post_email_states(
pg, _log, state, captured_code=captured_code, temp_mail_cfg=temp_mail_cfg,
recovery_already_bound=recovery_already_bound, recovery_session=recovery_session,
failure_hook=failure_hook, rounds=3,
)
_log('entry', f'登录后阶段={state}', 'INFO')
if state == 'code':
return _exchange_captured_code(
pg,
captured_code,
_log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
if state == 'consent':
return _click_consent_and_exchange(
pg,
captured_code,
_log,
failure_hook=failure_hook,
current_proxy=current_proxy,
token_proxy_getter=token_proxy_getter,
)
if failure_hook:
failure_hook('oauth_consent_fail')
_dump_auth_page(pg, _log)
_log('entry', f'未进入同意或登录页面,最终状态={state}', 'FAIL')
return False, None
except Exception as e:
_log('exception', f'异常: {_compact_exc(e)}', 'FAIL')
return False, None
finally:
try:
pg.remove_listener('request', on_request)
pg.remove_listener('framenavigated', on_frame_navigated)
except Exception:
pass
try:
pg.close()
except Exception:
pass
try:
success, token = _try_flow()
if success:
return True, token
except Exception as e:
_log('outer', f'首次尝试异常: {_compact_exc(e)}', 'FAIL')
return False, None