Add Yaohuo verification-based self-service signup

This commit is contained in:
zeer
2026-04-15 15:36:50 +08:00
parent de130f1052
commit a65b67485e
9 changed files with 568 additions and 20 deletions

View File

@@ -11,7 +11,7 @@ from sqlalchemy import event
from sqlalchemy.engine import Engine
from sqlalchemy.engine.url import make_url
from .services import Office365Service
from .services import Office365Service, YaohuoVerificationService
from .settings import Settings, load_settings
@@ -86,6 +86,7 @@ def create_app(
service = service_factory
app.extensions["office365_service"] = service
app.extensions["yaohuo_verification_service"] = YaohuoVerificationService(settings)
from .routes import bp_admin, bp_user

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import logging
import secrets
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, current_app, jsonify, render_template, request, session
@@ -10,7 +11,7 @@ from sqlalchemy import func, update
from . import db
from .models import AuditEvent, RedemptionCode, utc_now
from .services import Office365Service, ServiceConfigurationError, ServiceOperationError
from .services import Office365Service, ServiceConfigurationError, ServiceOperationError, YaohuoVerificationService
bp_admin = Blueprint("admin", __name__, url_prefix="/admin")
@@ -20,6 +21,7 @@ logger = logging.getLogger("office365_self_service.routes")
STATUS_AVAILABLE = "available"
STATUS_PROCESSING = "processing"
STATUS_USED = "used"
YAOHUO_SESSION_KEY = "yaohuo_verification"
def _settings():
@@ -30,6 +32,10 @@ def _service() -> Office365Service:
return current_app.extensions["office365_service"]
def _yaohuo_service() -> YaohuoVerificationService:
return current_app.extensions["yaohuo_verification_service"]
def _success(data=None, message: str = "ok", status: int = 200):
return jsonify({"success": True, "message": message, "data": data}), status
@@ -73,6 +79,42 @@ def _json_payload() -> dict:
return request.get_json(silent=True) or {}
def _session_verification_state() -> dict:
payload = session.get(YAOHUO_SESSION_KEY)
return payload if isinstance(payload, dict) else {}
def _clear_yaohuo_verification() -> None:
session.pop(YAOHUO_SESSION_KEY, None)
def _verification_expired(expires_at: str | None) -> bool:
if not expires_at:
return True
try:
return datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc)
except ValueError:
return True
def _store_yaohuo_verification(target_user_id: str, code: str, expires_at: str) -> None:
session[YAOHUO_SESSION_KEY] = {
"targetUserId": target_user_id,
"code": code,
"expiresAt": expires_at,
"verified": False,
}
def _mark_yaohuo_verified() -> None:
state = _session_verification_state()
if not state:
return
state["verified"] = True
state.pop("code", None)
session[YAOHUO_SESSION_KEY] = state
def _code_match(code: str):
return func.lower(RedemptionCode.code) == code.lower()
@@ -82,6 +124,7 @@ def _health_payload() -> dict:
return {
"platform": settings.to_public_dict(),
"authenticated": _authenticated(),
"yaohuoVerified": bool(_session_verification_state().get("verified")),
}
@@ -112,6 +155,68 @@ def _build_audit_event(
)
def _provision_account(username: str):
actor = _current_actor("public")
try:
user_result = _service().create_user(username=username)
except ServiceConfigurationError as exc:
_record_audit_events(
_build_audit_event(
"account_provisioned",
status="failed",
actor=actor,
username=username,
details={"message": str(exc)},
)
)
return _error(str(exc), status=503)
except ServiceOperationError as exc:
_record_audit_events(
_build_audit_event(
"account_provisioned",
status="failed",
actor=actor,
username=username,
principal_name=(exc.details or {}).get("userPrincipalName") if isinstance(exc.details, dict) else None,
details={"message": exc.message, "serviceDetails": exc.details},
)
)
return _error(exc.message, status=exc.status_code, details=exc.details)
except Exception as exc:
logger.exception("妖火验证建号时发生未预期错误")
_record_audit_events(
_build_audit_event(
"account_provisioned",
status="failed",
actor=actor,
username=username,
details={"message": f"创建账号失败: {exc}"},
)
)
return _error(f"创建账号失败: {exc}", status=500)
_record_audit_events(
_build_audit_event(
"account_provisioned",
status="success",
actor=actor,
username=username,
principal_name=user_result.get("userPrincipalName"),
details={
"licenseAssigned": user_result.get("licenseAssigned"),
"licenseMessage": user_result.get("licenseMessage"),
"source": "yaohuo_verification",
},
)
)
return _success({
"userPrincipalName": user_result.get("userPrincipalName"),
"temporaryPassword": user_result.get("temporaryPassword"),
"licenseAssigned": user_result.get("licenseAssigned"),
"licenseMessage": user_result.get("licenseMessage"),
}, "账号开通成功!", status=201)
def _record_audit_events(*events: AuditEvent) -> None:
pending = [event for event in events if event is not None]
if not pending:
@@ -501,6 +606,98 @@ def redeem():
}, "账号开通成功!", status=201)
@bp_user.post("/api/yaohuo/send-code")
def yaohuo_send_code():
payload = _json_payload()
target_user_id = str(payload.get("targetUserId", "")).strip()
actor = _current_actor("public")
try:
code = _yaohuo_service().generate_code()
expires_at = _yaohuo_service().expires_at().isoformat()
_yaohuo_service().send_verification_code(target_user_id, code)
_store_yaohuo_verification(target_user_id, code, expires_at)
except ServiceConfigurationError as exc:
return _error(str(exc), status=503)
except ServiceOperationError as exc:
_record_audit_events(
_build_audit_event(
"yaohuo_verification_requested",
status="failed",
actor=actor,
details={"targetUserId": target_user_id, "message": exc.message},
)
)
return _error(exc.message, status=exc.status_code, details=exc.details)
except ValueError as exc:
return _error(str(exc), status=400)
_record_audit_events(
_build_audit_event(
"yaohuo_verification_requested",
actor=actor,
details={"targetUserId": target_user_id},
)
)
return _success({"expiresAt": expires_at}, "验证码已发送到指定妖火 ID 的私信。")
@bp_user.post("/api/yaohuo/verify")
def yaohuo_verify():
payload = _json_payload()
submitted_code = str(payload.get("code", "")).strip()
state = _session_verification_state()
actor = _current_actor("public")
if not state:
return _error("请先发送验证码。", status=400)
if _verification_expired(state.get("expiresAt")):
_clear_yaohuo_verification()
return _error("验证码已过期,请重新发送。", status=410)
if not submitted_code:
return _error("请输入验证码。", status=400)
if submitted_code != str(state.get("code", "")):
_record_audit_events(
_build_audit_event(
"yaohuo_verified",
status="failed",
actor=actor,
details={"targetUserId": state.get("targetUserId"), "message": "验证码错误。"},
)
)
return _error("验证码错误。", status=400)
_mark_yaohuo_verified()
_record_audit_events(
_build_audit_event(
"yaohuo_verified",
actor=actor,
details={"targetUserId": state.get("targetUserId")},
)
)
return _success({"verified": True}, "妖火论坛验证成功。")
@bp_user.post("/api/yaohuo/provision")
def yaohuo_provision():
payload = _json_payload()
username = str(payload.get("username", "")).strip().lower()
state = _session_verification_state()
if not username:
return _error("请输入用户名。", status=400)
if not state or not state.get("verified"):
return _error("请先完成妖火论坛验证。", status=403)
if _verification_expired(state.get("expiresAt")):
_clear_yaohuo_verification()
return _error("验证状态已过期,请重新验证。", status=410)
result = _provision_account(username)
if result[1] < 400:
_clear_yaohuo_verification()
return result
@bp_user.get("/api/config")
def config():
return _success(_settings().to_public_dict())

View File

@@ -1,8 +1,13 @@
from __future__ import annotations
import logging
import random
import re
from datetime import datetime, timedelta, timezone
from typing import Any
import requests
from .graph import GraphAPIError, GraphClient, TokenManager
from .settings import Settings
@@ -191,3 +196,72 @@ class Office365Service:
if "already exists" in lowered or "another object with the same value" in lowered:
status_code = 409
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
class YaohuoVerificationService:
def __init__(self, settings: Settings):
self.settings = settings
def verification_ready(self) -> bool:
return self.settings.yaohuo_verification_enabled and bool(self.settings.yaohuo_cookie)
def ensure_ready(self) -> None:
if not self.settings.yaohuo_verification_enabled:
raise ServiceConfigurationError("妖火论坛验证功能未启用。")
if not self.settings.yaohuo_cookie:
raise ServiceConfigurationError("YAOHUO_COOKIE 未配置,无法发送妖火私信验证码。")
def generate_code(self) -> str:
return f"{random.randint(0, 999999):06d}"
def expires_at(self) -> datetime:
return datetime.now(timezone.utc) + timedelta(seconds=self.settings.yaohuo_verification_code_ttl_seconds)
def send_verification_code(self, target_user_id: str, code: str) -> None:
self.ensure_ready()
normalized_user_id = self._normalize_target_user_id(target_user_id)
content = f"【Office 365 自助开通验证】您的验证码是:{code}{self.settings.yaohuo_verification_code_ttl_seconds // 60} 分钟内有效。"
payload = {
"touseridlist": normalized_user_id,
"content": content,
"action": "gomod",
"classid": "0",
"siteid": "1000",
"types": "",
"issystem": "",
"g": "发送消息",
}
try:
response = requests.post(
self.settings.yaohuo_message_url,
data=payload,
headers={
"Cookie": self.settings.yaohuo_cookie,
"Referer": self.settings.yaohuo_message_url,
"User-Agent": "Mozilla/5.0",
},
timeout=30,
)
except requests.RequestException as exc:
raise ServiceOperationError(f"发送妖火验证码失败: {exc}", status_code=502) from exc
if response.status_code >= 400:
raise ServiceOperationError(
f"发送妖火验证码失败,状态码 {response.status_code}",
status_code=502,
)
body = response.text
if "发短信息" in body and "发送成功" not in body and "返回上级" in body:
logger.warning("妖火私信发送结果无法明确判断成功,按成功处理。")
return
if any(keyword in body for keyword in ("成功", "发送成功", "发送完毕")):
return
def _normalize_target_user_id(self, target_user_id: str) -> str:
normalized = re.sub(r"\s+", "", str(target_user_id or ""))
if not normalized or not normalized.isdigit():
raise ValueError("请输入有效的妖火 ID。")
return normalized

View File

@@ -64,6 +64,10 @@ class Settings:
token_endpoint: str
scope: str
database_url: str
yaohuo_cookie: str
yaohuo_message_url: str
yaohuo_verification_enabled: bool
yaohuo_verification_code_ttl_seconds: int
default_page_size: int = 25
max_page_size: int = 100
validation_errors: tuple[str, ...] = field(default_factory=tuple)
@@ -92,6 +96,7 @@ class Settings:
"forceChangePassword": self.force_change_password,
"pageSize": self.default_page_size,
"maxPageSize": self.max_page_size,
"yaohuoVerificationEnabled": self.yaohuo_verification_enabled,
}
@@ -149,6 +154,11 @@ def load_settings() -> Settings:
token_endpoint=token_endpoint,
scope=scope,
database_url=database_url,
yaohuo_cookie=os.getenv("YAOHUO_COOKIE", "").strip(),
yaohuo_message_url=os.getenv("YAOHUO_MESSAGE_URL", "https://www.yaohuo.me/bbs/messagelist_add.aspx").strip()
or "https://www.yaohuo.me/bbs/messagelist_add.aspx",
yaohuo_verification_enabled=_env_bool("YAOHUO_VERIFICATION_ENABLED", False),
yaohuo_verification_code_ttl_seconds=min(max(_env_int("YAOHUO_VERIFICATION_CODE_TTL_SECONDS", 600), 60), 3600),
default_page_size=min(max(_env_int("DEFAULT_PAGE_SIZE", 25), 1), 100),
max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500),
validation_errors=tuple(validation_errors),

View File

@@ -7,17 +7,23 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background-color: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
.redemption-card { max-width: 500px; width: 100%; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.redemption-card { max-width: 640px; width: 100%; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.result-box { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; }
.mode-switch .btn { flex: 1; }
</style>
</head>
<body>
<div class="redemption-card">
<h3 class="text-center mb-4">{{ settings.app_name }}</h3>
<p class="text-center text-muted mb-4">兑换码开通 Office 365 账号</p>
<p class="text-center text-muted mb-4">支持兑换码开通,或通过妖火论坛验证后免兑换码开通</p>
<div id="message"></div>
<div class="btn-group w-100 mode-switch mb-4" role="group">
<button type="button" class="btn btn-primary" id="redeemModeBtn">兑换码开通</button>
<button type="button" class="btn btn-outline-primary" id="yaohuoModeBtn">妖火验证开通</button>
</div>
<div id="redeemForm">
<div class="mb-3">
<label class="form-label">兑换码</label>
@@ -39,6 +45,37 @@
<button type="submit" class="btn btn-primary w-100" id="redeemBtn">立即开通</button>
</div>
<div id="yaohuoForm" class="d-none">
<div class="mb-3">
<label class="form-label">妖火 ID</label>
<input type="text" class="form-control" id="yaohuoIdInput" placeholder="请输入对方妖火 ID">
<div class="form-text">系统会通过后台已登录的妖火账号,向该 ID 发送私信验证码。</div>
</div>
<div class="d-grid gap-2 mb-3">
<button type="button" class="btn btn-outline-primary" id="sendYaohuoCodeBtn">发送验证码</button>
</div>
<div class="mb-3">
<label class="form-label">验证码</label>
<input type="text" class="form-control" id="yaohuoCodeInput" placeholder="请输入收到的验证码">
</div>
<div class="d-grid gap-2 mb-3">
<button type="button" class="btn btn-outline-success" id="verifyYaohuoBtn">验证妖火身份</button>
</div>
<div class="mb-3">
<label class="form-label">用户名</label>
{% if settings.default_domain %}
<div class="input-group">
<input type="text" class="form-control" id="yaohuoUsernameInput" placeholder="请输入用户名">
<span class="input-group-text">@{{ settings.default_domain }}</span>
</div>
{% else %}
<input type="text" class="form-control" id="yaohuoUsernameInput" placeholder="请输入完整邮箱地址,例如 alice@example.com">
{% endif %}
<div class="form-text">完成妖火验证后,无需兑换码即可开通账号。</div>
</div>
<button type="button" class="btn btn-success w-100" id="yaohuoProvisionBtn">验证后免码开通</button>
</div>
<div id="successResult" class="d-none">
<div class="text-center mb-4">
<div class="text-success mb-3">
@@ -70,12 +107,53 @@
.replace(/'/g, '&#39;');
}
function showMessage(html) {
document.getElementById('message').innerHTML = html;
}
function switchMode(mode) {
const redeemForm = document.getElementById('redeemForm');
const yaohuoForm = document.getElementById('yaohuoForm');
const redeemModeBtn = document.getElementById('redeemModeBtn');
const yaohuoModeBtn = document.getElementById('yaohuoModeBtn');
showMessage('');
if (mode === 'yaohuo') {
redeemForm.classList.add('d-none');
yaohuoForm.classList.remove('d-none');
redeemModeBtn.className = 'btn btn-outline-primary';
yaohuoModeBtn.className = 'btn btn-primary';
return;
}
redeemForm.classList.remove('d-none');
yaohuoForm.classList.add('d-none');
redeemModeBtn.className = 'btn btn-primary';
yaohuoModeBtn.className = 'btn btn-outline-primary';
}
async function showProvisionSuccess(data) {
document.getElementById('redeemForm').classList.add('d-none');
document.getElementById('yaohuoForm').classList.add('d-none');
document.getElementById('successResult').classList.remove('d-none');
document.getElementById('resultEmail').textContent = data.userPrincipalName;
document.getElementById('resultPassword').textContent = data.temporaryPassword;
const licenseWarning = document.getElementById('licenseWarning');
if (data.licenseAssigned === false && data.licenseMessage) {
licenseWarning.textContent = data.licenseMessage;
licenseWarning.classList.remove('d-none');
} else {
licenseWarning.classList.add('d-none');
licenseWarning.textContent = '';
}
}
document.getElementById('redeemBtn').addEventListener('click', async () => {
const code = document.getElementById('codeInput').value.trim();
const username = document.getElementById('usernameInput').value.trim();
if (!code || !username) {
document.getElementById('message').innerHTML = '<div class="alert alert-danger">请填写完整的兑换码和用户名</div>';
showMessage('<div class="alert alert-danger">请填写完整的兑换码和用户名</div>');
return;
}
@@ -92,29 +170,102 @@
const data = await response.json();
if (data.success) {
document.getElementById('redeemForm').classList.add('d-none');
document.getElementById('successResult').classList.remove('d-none');
document.getElementById('resultEmail').textContent = data.data.userPrincipalName;
document.getElementById('resultPassword').textContent = data.data.temporaryPassword;
const licenseWarning = document.getElementById('licenseWarning');
if (data.data.licenseAssigned === false && data.data.licenseMessage) {
licenseWarning.textContent = data.data.licenseMessage;
licenseWarning.classList.remove('d-none');
} else {
licenseWarning.classList.add('d-none');
licenseWarning.textContent = '';
}
showProvisionSuccess(data.data);
} else {
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${escapeHtml(data.message)}</div>`;
showMessage(`<div class="alert alert-danger">${escapeHtml(data.message)}</div>`);
btn.disabled = false;
btn.textContent = '立即开通';
}
} catch (e) {
document.getElementById('message').innerHTML = '<div class="alert alert-danger">网络错误,请稍后重试</div>';
showMessage('<div class="alert alert-danger">网络错误,请稍后重试</div>');
btn.disabled = false;
btn.textContent = '立即开通';
}
});
document.getElementById('redeemModeBtn').addEventListener('click', () => switchMode('redeem'));
document.getElementById('yaohuoModeBtn').addEventListener('click', () => switchMode('yaohuo'));
document.getElementById('sendYaohuoCodeBtn').addEventListener('click', async () => {
const targetUserId = document.getElementById('yaohuoIdInput').value.trim();
if (!targetUserId) {
showMessage('<div class="alert alert-danger">请输入妖火 ID</div>');
return;
}
const btn = document.getElementById('sendYaohuoCodeBtn');
btn.disabled = true;
btn.textContent = '发送中...';
try {
const response = await fetch('/api/yaohuo/send-code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ targetUserId })
});
const data = await response.json();
if (data.success) {
showMessage(`<div class="alert alert-success">${escapeHtml(data.message)}</div>`);
} else {
showMessage(`<div class="alert alert-danger">${escapeHtml(data.message)}</div>`);
}
} catch (e) {
showMessage('<div class="alert alert-danger">网络错误,请稍后重试</div>');
} finally {
btn.disabled = false;
btn.textContent = '发送验证码';
}
});
document.getElementById('verifyYaohuoBtn').addEventListener('click', async () => {
const code = document.getElementById('yaohuoCodeInput').value.trim();
if (!code) {
showMessage('<div class="alert alert-danger">请输入验证码</div>');
return;
}
const response = await fetch('/api/yaohuo/verify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code })
});
const data = await response.json();
if (data.success) {
showMessage(`<div class="alert alert-success">${escapeHtml(data.message)}</div>`);
} else {
showMessage(`<div class="alert alert-danger">${escapeHtml(data.message)}</div>`);
}
});
document.getElementById('yaohuoProvisionBtn').addEventListener('click', async () => {
const username = document.getElementById('yaohuoUsernameInput').value.trim();
if (!username) {
showMessage('<div class="alert alert-danger">请输入用户名</div>');
return;
}
const btn = document.getElementById('yaohuoProvisionBtn');
btn.disabled = true;
btn.textContent = '开通中...';
try {
const response = await fetch('/api/yaohuo/provision', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ username })
});
const data = await response.json();
if (data.success) {
showProvisionSuccess(data.data);
} else {
showMessage(`<div class="alert alert-danger">${escapeHtml(data.message)}</div>`);
btn.disabled = false;
btn.textContent = '验证后免码开通';
}
} catch (e) {
showMessage('<div class="alert alert-danger">网络错误,请稍后重试</div>');
btn.disabled = false;
btn.textContent = '验证后免码开通';
}
});
</script>
</body>
</html>