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

@@ -33,3 +33,9 @@ DEFAULT_USAGE_LOCATION=US
DEFAULT_LICENSE_SKU=ENTERPRISEPACK DEFAULT_LICENSE_SKU=ENTERPRISEPACK
LICENSE_ASSIGNMENT_REQUIRED=false LICENSE_ASSIGNMENT_REQUIRED=false
FORCE_CHANGE_PASSWORD=true FORCE_CHANGE_PASSWORD=true
# 妖火论坛私信验证
YAOHUO_VERIFICATION_ENABLED=false
YAOHUO_COOKIE=
YAOHUO_MESSAGE_URL=https://www.yaohuo.me/bbs/messagelist_add.aspx
YAOHUO_VERIFICATION_CODE_TTL_SECONDS=600

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# AGENTS.md
## Stack And Entry Points
- Single-package Flask app. Runtime entrypoint is `app.py`, which exposes `app = create_app()` for Gunicorn and local runs.
- App wiring lives in `office365_self_service/__init__.py`: settings load first, logging is configured, SQLite directories are created if needed, `db.create_all()` runs during app startup, and blueprints from `office365_self_service/routes.py` are registered there.
- Core behavior is split by file: `routes.py` handles both HTML pages and JSON APIs, `services.py` contains Office 365 business logic, `graph.py` wraps Microsoft Graph calls, and `models.py` defines the SQLite-backed SQLAlchemy models.
## Commands
- Create a local env and install deps with `python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`.
- Run locally with `python3 app.py`.
- Run the test suite with `python3 -m unittest`.
- Run one test class with `python3 -m unittest tests.test_app.AppRouteTests`.
- Run one test method with `python3 -m unittest tests.test_app.AppRouteTests.test_redeem_marks_code_used_and_prevents_second_use`.
- Container flow is `docker compose up -d --build`, with healthcheck hitting `http://localhost:8000/api/health`.
## Verified Repo Conventions
- There is no configured linter, formatter, typechecker, pytest, or task runner in this repo. Do not invent `pytest`, `ruff`, `mypy`, `npm`, or `make` workflows.
- Tests use the standard library `unittest` module in `tests/test_app.py`.
- The Docker image runs Gunicorn via `gunicorn -w 2 -b 0.0.0.0:8000 app:app`; local development uses Flask's built-in server from `app.py`.
## Config And Data Gotchas
- Copy `.env.example` to `.env` before running locally or via Docker Compose.
- Graph readiness is configuration-driven in `office365_self_service/settings.py`. Missing `CLIENT_ID`, `TENANT_ID`, `CLIENT_SECRET`, or `DEFAULT_PASSWORD` does not stop app startup, but service calls fail later with configuration errors.
- `WEB_AUTH_ENABLED=true` only enables real admin login protection when both `ADMIN_USERNAME` and `ADMIN_PASSWORD` are set. Otherwise settings downgrade to effectively unauthenticated admin APIs and emit a warning.
- Relative SQLite URLs such as `sqlite:///redemption.db` resolve through Flask into `instance/redemption.db`.
- If `DATABASE_URL` is set to a container path like `sqlite:////app/data/redemption.db` outside Docker, settings automatically remap it to the matching local repo path and record a warning.
- App startup creates tables automatically with `db.create_all()`. There are no migrations in this repo.
- Logs are written to `logs/office365_self_service.log` via a rotating file handler during app startup.
## API And Behavior Notes
- Public health/config endpoints are `/api/health` and `/api/config`. Admin also has `/admin/api/health`, `/admin/api/session`, and authenticated config/data endpoints under `/admin/api/*`.
- Redemption is stateful in the database: codes move `available -> processing -> used`, and failures release codes back to `available`.
- Username handling is easy to guess wrong: plain usernames are lowercased and expanded with `DEFAULT_DOMAIN`; full UPNs are accepted as-is. If `DEFAULT_DOMAIN` is empty, callers must submit a full email address.
- License assignment is optional. When `DEFAULT_LICENSE_SKU` is set and `LICENSE_ASSIGNMENT_REQUIRED=true`, failed license assignment triggers deletion of the newly created user and surfaces an error instead of a warning.
## Editing Guidance
- Preserve the app-factory shape and the `service_factory` injection seam in `create_app()`. Tests rely on injecting fake services there.
- Keep focused verification lightweight: for most backend changes, run the relevant `python3 -m unittest` target rather than assuming extra tooling exists.

View File

@@ -86,6 +86,10 @@ docker compose down
| `WEB_AUTH_ENABLED` | 可选 | 后台登录保护 | `true``false` | | `WEB_AUTH_ENABLED` | 可选 | 后台登录保护 | `true``false` |
| `ADMIN_USERNAME` | 建议 | 后台登录用户名 | 自定义 | | `ADMIN_USERNAME` | 建议 | 后台登录用户名 | 自定义 |
| `ADMIN_PASSWORD` | 建议 | 后台登录密码 | 自定义 | | `ADMIN_PASSWORD` | 建议 | 后台登录密码 | 自定义 |
| `YAOHUO_VERIFICATION_ENABLED` | 可选 | 是否启用妖火论坛私信验证免兑换码开通 | `true``false` |
| `YAOHUO_COOKIE` | 妖火验证必填 | 后台已登录妖火账号的 Cookie | 浏览器复制完整 Cookie |
| `YAOHUO_MESSAGE_URL` | 可选 | 妖火发私信地址 | 默认 `https://www.yaohuo.me/bbs/messagelist_add.aspx` |
| `YAOHUO_VERIFICATION_CODE_TTL_SECONDS` | 可选 | 妖火验证码有效期(秒) | 默认 `600` |
| `SESSION_SECRET` | 建议 | Flask 会话密钥 | 随机长字符串 | | `SESSION_SECRET` | 建议 | Flask 会话密钥 | 随机长字符串 |
| `HOST` | 可选 | 服务监听地址 | 默认 `0.0.0.0` | | `HOST` | 可选 | 服务监听地址 | 默认 `0.0.0.0` |
| `PORT` | 可选 | 服务监听端口 | 默认 `8000` | | `PORT` | 可选 | 服务监听端口 | 默认 `8000` |
@@ -141,6 +145,15 @@ docker compose down
3. 点击「立即开通」 3. 点击「立即开通」
4. 系统返回临时密码,首次登录后需更改密码 4. 系统返回临时密码,首次登录后需更改密码
### 妖火论坛验证开通
1.`.env` 中启用 `YAOHUO_VERIFICATION_ENABLED=true`
2. 配置可用的 `YAOHUO_COOKIE`
3. 用户在首页切换到「妖火验证开通」
4. 输入目标妖火 ID系统向该 ID 发送私信验证码
5. 对方提供验证码后完成验证
6. 验证通过后无需兑换码即可直接开通账号
## 技术栈 ## 技术栈
- Python 3.9+ - Python 3.9+

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import logging import logging
import secrets import secrets
from datetime import datetime, timezone
from functools import wraps from functools import wraps
from flask import Blueprint, current_app, jsonify, render_template, request, session from flask import Blueprint, current_app, jsonify, render_template, request, session
@@ -10,7 +11,7 @@ from sqlalchemy import func, update
from . import db from . import db
from .models import AuditEvent, RedemptionCode, utc_now 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") bp_admin = Blueprint("admin", __name__, url_prefix="/admin")
@@ -20,6 +21,7 @@ logger = logging.getLogger("office365_self_service.routes")
STATUS_AVAILABLE = "available" STATUS_AVAILABLE = "available"
STATUS_PROCESSING = "processing" STATUS_PROCESSING = "processing"
STATUS_USED = "used" STATUS_USED = "used"
YAOHUO_SESSION_KEY = "yaohuo_verification"
def _settings(): def _settings():
@@ -30,6 +32,10 @@ def _service() -> Office365Service:
return current_app.extensions["office365_service"] 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): def _success(data=None, message: str = "ok", status: int = 200):
return jsonify({"success": True, "message": message, "data": data}), status return jsonify({"success": True, "message": message, "data": data}), status
@@ -73,6 +79,42 @@ def _json_payload() -> dict:
return request.get_json(silent=True) or {} 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): def _code_match(code: str):
return func.lower(RedemptionCode.code) == code.lower() return func.lower(RedemptionCode.code) == code.lower()
@@ -82,6 +124,7 @@ def _health_payload() -> dict:
return { return {
"platform": settings.to_public_dict(), "platform": settings.to_public_dict(),
"authenticated": _authenticated(), "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: def _record_audit_events(*events: AuditEvent) -> None:
pending = [event for event in events if event is not None] pending = [event for event in events if event is not None]
if not pending: if not pending:
@@ -501,6 +606,98 @@ def redeem():
}, "账号开通成功!", status=201) }, "账号开通成功!", 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") @bp_user.get("/api/config")
def config(): def config():
return _success(_settings().to_public_dict()) return _success(_settings().to_public_dict())

View File

@@ -1,8 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import random
import re
from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
import requests
from .graph import GraphAPIError, GraphClient, TokenManager from .graph import GraphAPIError, GraphClient, TokenManager
from .settings import Settings from .settings import Settings
@@ -191,3 +196,72 @@ class Office365Service:
if "already exists" in lowered or "another object with the same value" in lowered: if "already exists" in lowered or "another object with the same value" in lowered:
status_code = 409 status_code = 409
return ServiceOperationError(message=message, status_code=status_code, details=exc.response) 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 token_endpoint: str
scope: str scope: str
database_url: 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 default_page_size: int = 25
max_page_size: int = 100 max_page_size: int = 100
validation_errors: tuple[str, ...] = field(default_factory=tuple) validation_errors: tuple[str, ...] = field(default_factory=tuple)
@@ -92,6 +96,7 @@ class Settings:
"forceChangePassword": self.force_change_password, "forceChangePassword": self.force_change_password,
"pageSize": self.default_page_size, "pageSize": self.default_page_size,
"maxPageSize": self.max_page_size, "maxPageSize": self.max_page_size,
"yaohuoVerificationEnabled": self.yaohuo_verification_enabled,
} }
@@ -149,6 +154,11 @@ def load_settings() -> Settings:
token_endpoint=token_endpoint, token_endpoint=token_endpoint,
scope=scope, scope=scope,
database_url=database_url, 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), 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), max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500),
validation_errors=tuple(validation_errors), 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"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
body { background-color: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 0; } 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; } .result-box { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; }
.mode-switch .btn { flex: 1; }
</style> </style>
</head> </head>
<body> <body>
<div class="redemption-card"> <div class="redemption-card">
<h3 class="text-center mb-4">{{ settings.app_name }}</h3> <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 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 id="redeemForm">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">兑换码</label> <label class="form-label">兑换码</label>
@@ -39,6 +45,37 @@
<button type="submit" class="btn btn-primary w-100" id="redeemBtn">立即开通</button> <button type="submit" class="btn btn-primary w-100" id="redeemBtn">立即开通</button>
</div> </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 id="successResult" class="d-none">
<div class="text-center mb-4"> <div class="text-center mb-4">
<div class="text-success mb-3"> <div class="text-success mb-3">
@@ -70,12 +107,53 @@
.replace(/'/g, '&#39;'); .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 () => { document.getElementById('redeemBtn').addEventListener('click', async () => {
const code = document.getElementById('codeInput').value.trim(); const code = document.getElementById('codeInput').value.trim();
const username = document.getElementById('usernameInput').value.trim(); const username = document.getElementById('usernameInput').value.trim();
if (!code || !username) { if (!code || !username) {
document.getElementById('message').innerHTML = '<div class="alert alert-danger">请填写完整的兑换码和用户名</div>'; showMessage('<div class="alert alert-danger">请填写完整的兑换码和用户名</div>');
return; return;
} }
@@ -92,29 +170,102 @@
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
document.getElementById('redeemForm').classList.add('d-none'); showProvisionSuccess(data.data);
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 { } else {
licenseWarning.classList.add('d-none'); showMessage(`<div class="alert alert-danger">${escapeHtml(data.message)}</div>`);
licenseWarning.textContent = '';
}
} else {
document.getElementById('message').innerHTML = `<div class="alert alert-danger">${escapeHtml(data.message)}</div>`;
btn.disabled = false; btn.disabled = false;
btn.textContent = '立即开通'; btn.textContent = '立即开通';
} }
} catch (e) { } catch (e) {
document.getElementById('message').innerHTML = '<div class="alert alert-danger">网络错误,请稍后重试</div>'; showMessage('<div class="alert alert-danger">网络错误,请稍后重试</div>');
btn.disabled = false; btn.disabled = false;
btn.textContent = '立即开通'; 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> </script>
</body> </body>
</html> </html>

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from office365_self_service import create_app, db from office365_self_service import create_app, db
from office365_self_service.models import AuditEvent, RedemptionCode from office365_self_service.models import AuditEvent, RedemptionCode
from office365_self_service.services import Office365Service, ServiceConfigurationError, ServiceOperationError from office365_self_service.services import Office365Service, ServiceConfigurationError, ServiceOperationError, YaohuoVerificationService
from office365_self_service.settings import GRAPH_BASE_URL, GRAPH_SCOPE, Settings, TOKEN_ENDPOINT_TEMPLATE, load_settings from office365_self_service.settings import GRAPH_BASE_URL, GRAPH_SCOPE, Settings, TOKEN_ENDPOINT_TEMPLATE, load_settings
@@ -35,6 +35,10 @@ def build_settings(database_url: str, **overrides) -> Settings:
"token_endpoint": TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id), "token_endpoint": TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id),
"scope": GRAPH_SCOPE, "scope": GRAPH_SCOPE,
"database_url": database_url, "database_url": database_url,
"yaohuo_cookie": "",
"yaohuo_message_url": "https://www.yaohuo.me/bbs/messagelist_add.aspx",
"yaohuo_verification_enabled": False,
"yaohuo_verification_code_ttl_seconds": 600,
"validation_errors": (), "validation_errors": (),
"warnings": (), "warnings": (),
} }
@@ -87,6 +91,20 @@ class FakeGraphClient:
self.deleted_users.append(user_id) self.deleted_users.append(user_id)
class FakeYaohuoService:
def __init__(self):
self.sent = []
def generate_code(self) -> str:
return "123456"
def expires_at(self):
return datetime(2026, 4, 15, 12, 0, 0, tzinfo=timezone.utc)
def send_verification_code(self, target_user_id: str, code: str) -> None:
self.sent.append((target_user_id, code))
class AppRouteTests(unittest.TestCase): class AppRouteTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -99,6 +117,7 @@ class AppRouteTests(unittest.TestCase):
) )
self.app.testing = True self.app.testing = True
self.client = self.app.test_client() self.client = self.app.test_client()
self.app.extensions["yaohuo_verification_service"] = FakeYaohuoService()
with self.app.app_context(): with self.app.app_context():
db.drop_all() db.drop_all()
@@ -257,6 +276,34 @@ class AppRouteTests(unittest.TestCase):
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertFalse(payload["success"]) self.assertFalse(payload["success"])
def test_yaohuo_send_verify_and_provision_without_redemption_code(self):
response = self.client.post("/api/yaohuo/send-code", json={"targetUserId": "12345"})
payload = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertTrue(payload["success"])
self.assertEqual(self.app.extensions["yaohuo_verification_service"].sent, [("12345", "123456")])
verify = self.client.post("/api/yaohuo/verify", json={"code": "123456"})
verify_payload = verify.get_json()
self.assertEqual(verify.status_code, 200)
self.assertTrue(verify_payload["success"])
provision = self.client.post("/api/yaohuo/provision", json={"username": "alice"})
provision_payload = provision.get_json()
self.assertEqual(provision.status_code, 201)
self.assertTrue(provision_payload["success"])
self.assertEqual(provision_payload["data"]["userPrincipalName"], "alice@example.com")
self.assertEqual(self.service.calls, ["alice"])
def test_yaohuo_provision_requires_verified_session(self):
response = self.client.post("/api/yaohuo/provision", json={"username": "alice"})
payload = response.get_json()
self.assertEqual(response.status_code, 403)
self.assertFalse(payload["success"])
self.assertEqual(self.service.calls, [])
class ServiceBehaviorTests(unittest.TestCase): class ServiceBehaviorTests(unittest.TestCase):
def test_create_user_accepts_full_upn_without_default_domain(self): def test_create_user_accepts_full_upn_without_default_domain(self):
@@ -310,6 +357,17 @@ class ServiceBehaviorTests(unittest.TestCase):
self.assertEqual(fake_client.deleted_users, ["user-1"]) self.assertEqual(fake_client.deleted_users, ["user-1"])
class YaohuoServiceTests(unittest.TestCase):
def test_generate_code_returns_six_digits(self):
settings = build_settings("sqlite:////tmp/unused.db", yaohuo_verification_enabled=True, yaohuo_cookie="cookie")
service = YaohuoVerificationService(settings)
code = service.generate_code()
self.assertEqual(len(code), 6)
self.assertTrue(code.isdigit())
class ModelSerializationTests(unittest.TestCase): class ModelSerializationTests(unittest.TestCase):
def test_redemption_code_serializes_datetimes_as_utc_z(self): def test_redemption_code_serializes_datetimes_as_utc_z(self):
code = RedemptionCode( code = RedemptionCode(