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).
This commit is contained in:
daimon
2026-07-23 20:47:55 +08:00
commit d2db326c70
13 changed files with 5252 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
"""微软辅助邮箱相关页面。
A) 注册后绑定:「让我们来保护你的帐户」
#EmailAddress → #iNext → #iOttText → #iNext
(与 manage-webui abuse_recovery 一致)
B) OAuth 冷登录验证已绑定邮箱(Fluent 新 UI)
1. 验证你的电子邮件
#proof-confirmation-email-input + button[data-testid=primaryButton]「发送验证码」
2. 输入你的代码(6 格,无提交按钮,填完自动验证)
#codeEntry-0 … #codeEntry-5
3. 保持登录状态?
button[data-testid=secondaryButton]「否」
"""
import time
from controllers.temp_mail import client_from_config
# --- 绑定页 ---
BACKUP_EMAIL_SELECTOR = "#EmailAddress"
VERIFY_CODE_SELECTOR = "#iOttText"
NEXT_SELECTOR = "#iNext"
# --- 冷登录:确认辅助邮箱并发码 ---
PROOF_EMAIL_INPUT = "#proof-confirmation-email-input"
PROOF_EMAIL_INPUT_BY_LABEL = 'label[for="proof-confirmation-email-input"]'
# --- 冷登录:6 格验证码(填完自动提交,无按钮)---
CODE_ENTRY_PREFIX = "codeEntry-"
CODE_ENTRY_COUNT = 6
# --- 保持登录 ---
KMSI_NO_BTN = 'button[data-testid="secondaryButton"]'
def is_protect_account_page(page):
"""保护帐户 / 绑定备用邮箱页(#EmailAddress)。"""
try:
if page.locator(BACKUP_EMAIL_SELECTOR).count() > 0:
try:
if page.locator(BACKUP_EMAIL_SELECTOR).first.is_visible():
return True
except Exception:
return True
except Exception:
pass
try:
body = (page.locator("body").inner_text(timeout=600) or "")[:800]
except Exception:
body = ""
if "保护你的帐户" in body or "保护您的帐户" in body or "protect your account" in body.lower():
if page.locator("#iShowSkip").count() > 0 or page.locator(NEXT_SELECTOR).count() > 0:
return True
if "备用" in body or "电子邮件" in body:
return True
return False
def is_ott_code_page(page):
"""旧绑定流单框验证码 #iOttText。"""
try:
loc = page.locator(VERIFY_CODE_SELECTOR)
return loc.count() > 0 and loc.first.is_visible()
except Exception:
return False
def is_code_entry_page(page):
"""Fluent 6 格验证码页:「输入你的代码」#codeEntry-0..5。"""
try:
loc = page.locator(f"#{CODE_ENTRY_PREFIX}0")
if loc.count() > 0 and loc.first.is_visible():
return True
except Exception:
pass
try:
body = (page.locator("body").inner_text(timeout=500) or "")[:400]
if "输入你的代码" in body or "Enter your code" in body:
if page.locator(f"[id^='{CODE_ENTRY_PREFIX}']").count() >= 4:
return True
except Exception:
pass
return False
def is_proof_confirm_page(page):
"""登录时「验证你的电子邮件」:确认已绑定辅助邮箱并发送验证码。"""
try:
loc = page.locator(PROOF_EMAIL_INPUT)
if loc.count() > 0 and loc.first.is_visible():
return True
except Exception:
pass
try:
if page.locator(PROOF_EMAIL_INPUT_BY_LABEL).count() > 0:
return True
except Exception:
pass
try:
body = (page.locator("body").inner_text(timeout=600) or "")[:900]
except Exception:
body = ""
if any(t in body for t in ("验证你的电子邮件", "验证您的电子邮件", "Verify your email")):
if "发送验证码" in body or "Send code" in body or "已收到代码" in body:
return True
# 掩码辅助邮箱提示(不绑定具体域名)
if "or****" in body or "or*" in body or "@" in body and "发送" in body:
return True
return False
def is_kmsi_page(page):
"""保持登录状态?→ 是 / 否(secondaryButton)。"""
try:
body = (page.locator("body").inner_text(timeout=500) or "")[:500]
except Exception:
body = ""
if "保持登录" in body or "Stay signed in" in body or "保持登入" in body:
return True
try:
yes_btn = page.get_by_role("button", name="")
no_btn = page.get_by_test_id("secondaryButton")
if yes_btn.count() > 0 and no_btn.count() > 0 and no_btn.first.is_visible():
return True
if (
page.get_by_role("button", name="").count() > 0
and page.get_by_role("button", name="").count() > 0
and ("登录" in body or "signed" in body.lower())
):
return True
except Exception:
pass
return False
def _click_i_next(page):
for sel in (NEXT_SELECTOR, 'input#iNext', 'input[type="submit"][value="下一步"]'):
try:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click(timeout=5000)
return True
except Exception:
continue
try:
page.get_by_role("button", name="下一步").first.click(timeout=5000)
return True
except Exception:
return False
def _click_send_code(page):
"""点击「发送验证码」data-testid=primaryButton。"""
try:
btn = page.get_by_test_id("primaryButton")
if btn.count() > 0 and btn.first.is_visible():
btn.first.click(timeout=8000)
return True
except Exception:
pass
for text in ("发送验证码", "Send code", "Send verification code"):
try:
loc = page.get_by_role("button", name=text)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click(timeout=8000)
return True
except Exception:
pass
return False
def _fill_proof_email(page, address):
"""填入完整辅助邮箱到 proof-confirmation-email-input。"""
for sel in (PROOF_EMAIL_INPUT, 'input[type="email"]', 'input[name*="proof"]', 'input[placeholder*="电子"]'):
try:
loc = page.locator(sel)
if loc.count() <= 0:
continue
box = loc.first
if not box.is_visible():
continue
box.click(timeout=3000)
box.fill("")
box.fill(address, timeout=8000)
return True
except Exception:
continue
return False
def _fill_code_entry_digits(page, code):
"""6 格 #codeEntry-0..5 逐位输入;无提交按钮,满 6 位自动验证。"""
code = "".join(c for c in str(code) if c.isdigit())[:CODE_ENTRY_COUNT]
if len(code) < 4:
return False
def _set_digit(box, ch):
box.click(timeout=2000)
try:
box.fill("")
except Exception:
pass
box.fill(ch, timeout=3000)
# Fluent/React 需 input 事件才会跳格并在满位时自动提交
try:
box.evaluate(
"""(el, v) => {
const proto = window.HTMLInputElement && window.HTMLInputElement.prototype;
const desc = proto && Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && desc.set) { desc.set.call(el, v); }
else { el.value = v; }
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}""",
ch,
)
except Exception:
pass
try:
first = page.locator(f"#{CODE_ENTRY_PREFIX}0").first
first.wait_for(state="visible", timeout=8000)
for i, ch in enumerate(code):
box = page.locator(f"#{CODE_ENTRY_PREFIX}{i}").first
box.wait_for(state="visible", timeout=5000)
_set_digit(box, ch)
page.wait_for_timeout(100)
page.wait_for_timeout(1500)
return True
except Exception:
try:
first = page.locator(f"#{CODE_ENTRY_PREFIX}0").first
first.click(timeout=2000)
try:
first.fill("")
except Exception:
pass
first.type(code, delay=80, timeout=12000)
page.wait_for_timeout(1500)
return True
except Exception:
return False
def _click_kmsi_no(page, log=None):
"""保持登录状态?→ 点「否」data-testid=secondaryButton。"""
def _log(msg, level="INFO"):
if log:
log("kmsi", msg, level)
try:
btn = page.get_by_test_id("secondaryButton")
if btn.count() > 0 and btn.first.is_visible():
btn.first.click(timeout=5000)
_log("已点击 secondaryButton 否", "OK")
page.wait_for_timeout(1000)
return True
except Exception:
pass
for text in ("", "No"):
try:
loc = page.get_by_role("button", name=text)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click(timeout=5000)
_log(f"已点击按钮 {text}", "OK")
page.wait_for_timeout(1000)
return True
except Exception:
pass
try:
loc = page.locator(KMSI_NO_BTN)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click(timeout=5000)
_log("已点击 KMSI_NO_BTN", "OK")
page.wait_for_timeout(1000)
return True
except Exception:
pass
return False
def _skip_protect(page, log):
try:
skip = page.locator("#iShowSkip")
if skip.count() > 0 and skip.first.is_visible():
skip.first.click(timeout=4000)
if log:
log("recovery", "绑定失败后回退:已点 #iShowSkip 暂时跳过", "WARN")
page.wait_for_timeout(800)
return True
except Exception:
pass
return False
def bind_recovery_email(page, temp_mail_cfg, log=None, code_timeout=120):
"""在保护帐户页绑定备用邮箱并输入验证码。
成功返回 (True, session_dict)session 含 address/jwt 供 OAuth 冷登录复用。
失败返回 (False, None)。
"""
def _log(stage, msg, level="INFO"):
if log:
log(stage, msg, level)
if not is_protect_account_page(page) and not is_ott_code_page(page):
return False, None
if is_ott_code_page(page) and not is_protect_account_page(page):
try:
if page.locator(BACKUP_EMAIL_SELECTOR).count() == 0:
_log("recovery", "仅代码页且无邮箱框,跳过二次绑定", "WARN")
return True, None
except Exception:
pass
_log("recovery", "已在代码页但无法新建接码会话", "FAIL")
return False, None
client = client_from_config(temp_mail_cfg or {})
try:
addr, jwt = client.create_address()
except Exception as exc:
_log("recovery", f"创建临时邮箱失败: {exc}", "FAIL")
_skip_protect(page, log)
return False, None
session = {
"address": addr,
"jwt": jwt,
"base_url": client.base_url,
"admin_password": client.admin_password,
"domain": client.domain,
}
_log("recovery", f"临时邮箱已创建 addr={addr}(本任务独立 jwt", "OK")
after_ts = time.time()
try:
email_box = page.locator(BACKUP_EMAIL_SELECTOR).first
email_box.wait_for(state="visible", timeout=10000)
email_box.click(timeout=3000)
email_box.fill("")
email_box.fill(addr, timeout=5000)
page.wait_for_timeout(300)
if not _click_i_next(page):
raise RuntimeError("无法点击下一步提交备用邮箱")
_log("recovery", f"已提交备用邮箱 {addr}", "INFO")
except Exception as exp:
_log("recovery", f"填写备用邮箱失败: {exp}", "FAIL")
_skip_protect(page, log)
return False, None
try:
page.locator(VERIFY_CODE_SELECTOR).first.wait_for(state="visible", timeout=30000)
except Exception:
if is_protect_account_page(page):
_log("recovery", "提交后仍在保护帐户页", "WARN")
_skip_protect(page, log)
return False, None
if not is_ott_code_page(page):
_log("recovery", "未出现验证码输入框,视为可能已完成", "WARN")
return True, session
page.wait_for_timeout(2000)
code = client.wait_for_code(
timeout_sec=int((temp_mail_cfg or {}).get("code_timeout", code_timeout)),
poll_sec=float((temp_mail_cfg or {}).get("poll_interval", 3)),
after_ts=after_ts - 5,
log=log,
)
if not code:
_log("recovery", "未收到微软验证码,尝试暂时跳过", "FAIL")
try:
page.go_back(timeout=5000)
page.wait_for_timeout(1000)
_skip_protect(page, log)
except Exception:
pass
return False, None
try:
ott = page.locator(VERIFY_CODE_SELECTOR).first
ott.click(timeout=3000)
ott.fill("")
ott.fill(code, timeout=5000)
page.wait_for_timeout(300)
if not _click_i_next(page):
raise RuntimeError("无法点击下一步提交验证码")
_log("recovery", f"已提交验证码 code={code}", "OK")
page.wait_for_timeout(1500)
return True, session
except Exception as exc:
_log("recovery", f"提交验证码失败: {exc}", "FAIL")
return False, None
def _client_from_session(session, temp_mail_cfg):
"""用绑定阶段保存的 jwt 重建 client,才能收同一邮箱的验证码。"""
if not session or not session.get("jwt"):
return None
cfg = dict(temp_mail_cfg or {})
if session.get("base_url"):
cfg["base_url"] = session["base_url"]
if session.get("admin_password"):
cfg["admin_password"] = session["admin_password"]
if session.get("domain"):
cfg["domain"] = session["domain"]
client = client_from_config(cfg)
client.address = session.get("address")
client.jwt = session.get("jwt")
return client
def verify_bound_email_on_login(page, bound_session, temp_mail_cfg, log=None, code_timeout=180):
"""OAuth 冷登录验证已绑定辅助邮箱全流程。
bound_session: {address, jwt, ...} 注册绑定阶段保存。
步骤:
1) 填 #proof-confirmation-email-input + 点「发送验证码」
2) 轮询临时邮箱取码 → 填 #codeEntry-0..5(无提交按钮,自动验证)
3) 若出现「保持登录」→ 点 secondaryButton「否」
"""
def _log(stage, msg, level="INFO"):
if log:
log(stage, msg, level)
# 仅「保持登录」页:点否即可
if is_kmsi_page(page) and not is_proof_confirm_page(page) and not is_code_entry_page(page):
ok = _click_kmsi_no(page, log=log)
if ok:
_log("proof_verify", "仅 KMSI 页,已点「否」", "OK")
return ok
if not bound_session or not bound_session.get("address"):
_log("proof_verify", "无已绑定辅助邮箱会话,无法验证", "FAIL")
return False
bound_address = bound_session["address"]
client = _client_from_session(bound_session, temp_mail_cfg)
if client is None:
_log("proof_verify", "缺少绑定阶段 jwt,无法接码", "FAIL")
return False
# --- 发码页 ---
if is_proof_confirm_page(page):
if not _fill_proof_email(page, bound_address):
_log("proof_verify", f"无法填写辅助邮箱框 addr={bound_address}", "FAIL")
return False
_log("proof_verify", f"已填写辅助邮箱 {bound_address}", "INFO")
page.wait_for_timeout(300)
after_ts = time.time()
if not _click_send_code(page):
_log("proof_verify", "无法点击「发送验证码」", "FAIL")
return False
_log("proof_verify", "已点击发送验证码", "OK")
elif is_code_entry_page(page) or is_ott_code_page(page):
after_ts = time.time() - 30
_log("proof_verify", "已在代码页,直接接码", "INFO")
else:
return False
# 等 6 格或旧单框
code_ready = False
for _ in range(40):
if is_code_entry_page(page) or is_ott_code_page(page):
code_ready = True
break
# 「已收到代码」入口
try:
for text in ("已收到代码", "I have a code", "I already have a code"):
loc = page.get_by_text(text, exact=False)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click(timeout=4000)
page.wait_for_timeout(800)
break
except Exception:
pass
page.wait_for_timeout(500)
if not code_ready:
_log("proof_verify", "未出现验证码输入页", "FAIL")
return False
page.wait_for_timeout(1500)
code = client.wait_for_code(
timeout_sec=int((temp_mail_cfg or {}).get("code_timeout", code_timeout)),
poll_sec=float((temp_mail_cfg or {}).get("poll_interval", 3)),
after_ts=after_ts - 5,
log=log,
)
if not code:
_log("proof_verify", "未收到验证码", "FAIL")
return False
# 填码
if is_code_entry_page(page):
if not _fill_code_entry_digits(page, code):
_log("proof_verify", f"6 格填码失败 code={code}", "FAIL")
return False
_log("proof_verify", f"已填入 6 格验证码 code={code}(自动提交)", "OK")
else:
try:
ott = page.locator(VERIFY_CODE_SELECTOR).first
ott.click(timeout=3000)
ott.fill("")
ott.fill(code, timeout=5000)
if not _click_i_next(page):
page.keyboard.press("Enter")
_log("proof_verify", f"已提交单框验证码 code={code}", "OK")
except Exception as exc:
_log("proof_verify", f"单框填码失败: {exc}", "FAIL")
return False
# 等跳转 / KMSI
page.wait_for_timeout(2000)
for _ in range(15):
if is_kmsi_page(page):
if _click_kmsi_no(page, log=log):
_log("proof_verify", "已点保持登录「否」", "OK")
break
# 已到 consent / 其它页
try:
if page.locator('[data-testid="appConsentPrimaryButton"]').count() > 0:
break
if "localhost" in (page.url or "") and "code=" in (page.url or ""):
break
except Exception:
pass
page.wait_for_timeout(400)
# 再扫一次 KMSI(有时慢)
if is_kmsi_page(page):
_click_kmsi_no(page, log=log)
return True
+218
View File
@@ -0,0 +1,218 @@
"""CF Temp Mail 客户端:每任务独立地址 + JWT,避免多线程验证码串号。"""
import random
import re
import string
import threading
import time
import requests
# 公开发行:无内置密钥/域名,须由 config.json 的 temp_mail 段填写
DEFAULT_BASE = ""
DEFAULT_DOMAIN = ""
DEFAULT_ADMIN = ""
DEFAULT_PREFIX = "orx"
# 优先带「验证码/安全代码」上下文的数字,避免误匹配邮箱本地部分里的数字
_LABELED_CODE_RES = [
re.compile(r"(?:安全代码|验证码|security\s*code|verification\s*code)[^\d]{0,48}(\d{4,8})", re.I),
re.compile(r"(?:输入|enter)[^\d]{0,20}(?:代码|code)[^\d]{0,20}(\d{4,8})", re.I),
re.compile(r"(?:code|代码)\s*[:]\s*(\d{4,8})", re.I),
]
class TempMailClient:
"""线程安全:创建地址与收信均用本实例自己的 address/jwt,不共享全局邮箱。"""
def __init__(
self,
base_url=DEFAULT_BASE,
admin_password=DEFAULT_ADMIN,
domain=DEFAULT_DOMAIN,
name_prefix=DEFAULT_PREFIX,
enable_prefix=False,
timeout=30,
):
self.base_url = (base_url or DEFAULT_BASE).rstrip("/")
self.admin_password = admin_password or DEFAULT_ADMIN
self.domain = domain or DEFAULT_DOMAIN
self.name_prefix = name_prefix or DEFAULT_PREFIX
self.enable_prefix = bool(enable_prefix)
self.timeout = timeout
self._lock = threading.Lock()
self.address = None
self.jwt = None
self.address_id = None
self._session = requests.Session()
self._session.headers.update({"User-Agent": "OutlookRegister/1.0"})
def _unique_name(self):
# orx + mmddHHMMSS + 线程低位 + 随机,降低多线程碰撞
ts = time.strftime("%m%d%H%M%S")
tid = abs(threading.get_ident()) % 10000
rnd = "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
return f"{self.name_prefix}{ts}{tid:04d}{rnd}"
def create_address(self, name=None, domain=None):
"""POST /admin/new_address → 本实例独有 address + jwt。"""
name = name or self._unique_name()
domain = domain or self.domain
url = f"{self.base_url}/admin/new_address"
headers = {
"Content-Type": "application/json",
"x-admin-auth": self.admin_password,
}
payload = {
"enablePrefix": self.enable_prefix,
"name": name,
"domain": domain,
}
resp = self._session.post(url, json=payload, headers=headers, timeout=self.timeout)
resp.raise_for_status()
data = resp.json()
with self._lock:
self.address = data.get("address") or f"{name}@{domain}"
self.jwt = data.get("jwt")
self.address_id = data.get("address_id")
if not self.jwt:
raise RuntimeError(f"temp_mail create missing jwt: {data}")
return self.address, self.jwt
def list_mails(self, limit=20, offset=0):
"""仅用本实例 jwt 拉信,不会读到其它任务邮箱。"""
if not self.jwt:
raise RuntimeError("temp_mail: create_address first")
url = f"{self.base_url}/api/mails"
headers = {"Authorization": f"Bearer {self.jwt}"}
resp = self._session.get(
url,
params={"limit": limit, "offset": offset},
headers=headers,
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
# API 可能用 results 或 data
if isinstance(data, dict):
return data.get("results") or data.get("data") or []
if isinstance(data, list):
return data
return []
@staticmethod
def extract_code_from_text(text, exclude_substrings=None):
"""从邮件正文解析验证码。exclude_substrings:排除邮箱地址等中的数字片段。"""
if not text:
return None
text = str(text)
exclude = [str(x) for x in (exclude_substrings or []) if x]
def _ok(code):
if not code or not code.isdigit():
return False
# 勿把临时邮箱本地名里的连续数字当成验证码(日志曾误提 072468)
for ex in exclude:
if code in ex.replace("@", ""):
return False
return True
for rx in _LABELED_CODE_RES:
m = rx.search(text)
if m and _ok(m.group(1)):
return m.group(1)
# 兜底:独立 6 位(再 4-8 位),仍排除邮箱数字
for m in re.finditer(r"(?<!\d)(\d{6})(?!\d)", text):
if _ok(m.group(1)):
return m.group(1)
for m in re.finditer(r"(?<!\d)(\d{4,8})(?!\d)", text):
if _ok(m.group(1)):
return m.group(1)
return None
def _mail_blob(self, mail):
if not isinstance(mail, dict):
return str(mail)
parts = []
for k in (
"subject", "text", "content", "raw", "html", "message",
"source", "intro", "body", "preview",
):
v = mail.get(k)
if v:
parts.append(str(v))
# 嵌套
for k in ("mail", "data", "payload"):
v = mail.get(k)
if isinstance(v, dict):
parts.append(self._mail_blob(v))
return "\n".join(parts)
def wait_for_code(self, timeout_sec=120, poll_sec=3, after_ts=None, log=None):
"""轮询本邮箱直到解析出验证码。after_ts: 只认该时间之后的信(unix)。"""
deadline = time.time() + timeout_sec
seen = set()
while time.time() < deadline:
try:
mails = self.list_mails(limit=15, offset=0)
except Exception as exc:
if log:
log("temp_mail", f"list_mails 失败: {exc}", "WARN")
time.sleep(poll_sec)
continue
for mail in mails or []:
mid = None
if isinstance(mail, dict):
mid = mail.get("id") or mail.get("mail_id") or mail.get("message_id")
# 时间过滤(字段名因版本而异)
if after_ts:
for tk in ("created_at", "createdAt", "time", "date", "timestamp"):
tv = mail.get(tk)
if tv is None:
continue
try:
if isinstance(tv, (int, float)):
ts = float(tv)
if ts > 1e12:
ts /= 1000.0
else:
# 跳过无法解析的字符串时间,不因格式误杀
ts = None
if ts is not None and ts + 2 < after_ts:
continue
except Exception:
pass
break
key = mid if mid is not None else id(mail)
if key in seen:
continue
seen.add(key)
blob = self._mail_blob(mail)
code = self.extract_code_from_text(
blob,
exclude_substrings=[self.address, (self.address or "").split("@")[0]],
)
if code:
if log:
log("temp_mail", f"解析到验证码 code={code} addr={self.address}", "OK")
return code
time.sleep(poll_sec)
if log:
log("temp_mail", f"等待验证码超时 addr={self.address}", "FAIL")
return None
def client_from_config(cfg):
"""从 config['temp_mail'] 构建客户端。未配置时 base/admin/domain 为空。"""
cfg = cfg or {}
return TempMailClient(
base_url=(cfg.get("base_url") or "").strip(),
admin_password=(cfg.get("admin_password") or "").strip(),
domain=(cfg.get("domain") or "").strip(),
name_prefix=(cfg.get("name_prefix") or DEFAULT_PREFIX).strip() or DEFAULT_PREFIX,
enable_prefix=bool(cfg.get("enable_prefix", False)),
timeout=int(cfg.get("timeout", 30)),
)
if __name__ == "__main__":
print(smoke_test())