Files
office365-self-service/office365_self_service/routes.py

507 lines
15 KiB
Python

from __future__ import annotations
import json
import logging
import secrets
from functools import wraps
from flask import Blueprint, current_app, jsonify, render_template, request, session
from sqlalchemy import func, update
from . import db
from .models import AuditEvent, RedemptionCode, utc_now
from .services import Office365Service, ServiceConfigurationError, ServiceOperationError
bp_admin = Blueprint("admin", __name__, url_prefix="/admin")
bp_user = Blueprint("user", __name__)
logger = logging.getLogger("office365_self_service.routes")
STATUS_AVAILABLE = "available"
STATUS_PROCESSING = "processing"
STATUS_USED = "used"
def _settings():
return current_app.config["SETTINGS"]
def _service() -> Office365Service:
return current_app.extensions["office365_service"]
def _success(data=None, message: str = "ok", status: int = 200):
return jsonify({"success": True, "message": message, "data": data}), status
def _error(message: str, status: int = 400, details=None):
payload = {"success": False, "message": message}
if details is not None:
payload["details"] = details
return jsonify(payload), status
def _authenticated() -> bool:
settings = _settings()
if not settings.effective_auth_enabled:
return True
return bool(session.get("authenticated"))
def require_auth(view_func):
@wraps(view_func)
def wrapped(*args, **kwargs):
if not _authenticated():
return _error("请先登录后台管理平台。", status=401)
return view_func(*args, **kwargs)
return wrapped
def _handle_service_call(callback):
try:
return callback()
except ServiceConfigurationError as exc:
return _error(str(exc), status=503)
except ServiceOperationError as exc:
return _error(exc.message, status=exc.status_code, details=exc.details)
except ValueError as exc:
return _error(str(exc), status=400)
def _json_payload() -> dict:
return request.get_json(silent=True) or {}
def _code_match(code: str):
return func.lower(RedemptionCode.code) == code.lower()
def _health_payload() -> dict:
settings = _settings()
return {
"platform": settings.to_public_dict(),
"authenticated": _authenticated(),
}
def _current_actor(default: str = "system") -> str:
if _authenticated():
return session.get("admin_username") or _settings().admin_username or default
return default
def _build_audit_event(
event_type: str,
*,
status: str = "success",
actor: str | None = None,
code: str | None = None,
username: str | None = None,
principal_name: str | None = None,
details: dict | None = None,
) -> AuditEvent:
return AuditEvent(
event_type=event_type,
status=status,
actor=actor or "system",
code=code,
username=username,
principal_name=principal_name,
details=json.dumps(details, ensure_ascii=False, sort_keys=True) if details is not None else None,
)
def _record_audit_events(*events: AuditEvent) -> None:
pending = [event for event in events if event is not None]
if not pending:
return
try:
db.session.add_all(pending)
db.session.commit()
except Exception:
db.session.rollback()
logger.exception("写入审计日志失败,共 %s 条事件。", len(pending))
def _pagination_params() -> tuple[int, int, int]:
settings = _settings()
try:
page = int(request.args.get("page", "1"))
except ValueError:
page = 1
try:
page_size = int(request.args.get("pageSize", str(settings.default_page_size)))
except ValueError:
page_size = settings.default_page_size
page = max(page, 1)
page_size = min(max(page_size, 1), settings.max_page_size)
return page, page_size, (page - 1) * page_size
def _pagination_payload(page: int, page_size: int, total: int) -> dict[str, int]:
pages = (total + page_size - 1) // page_size if total else 0
return {
"page": page,
"pageSize": page_size,
"total": total,
"pages": pages,
}
def _normalize_pagination(page: int, page_size: int, total: int) -> tuple[int, int, int]:
pages = (total + page_size - 1) // page_size if total else 0
if pages and page > pages:
page = pages
return page, (page - 1) * page_size, pages
def _reserve_code(code: str) -> bool:
result = db.session.execute(
update(RedemptionCode)
.where(_code_match(code), RedemptionCode.status == STATUS_AVAILABLE)
.values(status=STATUS_PROCESSING)
)
db.session.commit()
return result.rowcount == 1
def _release_code(code: str) -> None:
db.session.rollback()
db.session.execute(
update(RedemptionCode)
.where(_code_match(code), RedemptionCode.status == STATUS_PROCESSING)
.values(
status=STATUS_AVAILABLE,
used_at=None,
used_by_username=None,
used_by_principal_name=None,
)
)
db.session.commit()
def _complete_redemption(code: str, username: str, principal_name: str | None) -> bool:
result = db.session.execute(
update(RedemptionCode)
.where(_code_match(code), RedemptionCode.status == STATUS_PROCESSING)
.values(
status=STATUS_USED,
used_at=utc_now(),
used_by_username=username,
used_by_principal_name=principal_name,
)
)
db.session.commit()
return result.rowcount == 1
@bp_admin.get("/")
def admin_index():
if not _authenticated():
return render_template("admin_login.html", settings=_settings())
return render_template("admin_dashboard.html", settings=_settings())
@bp_admin.get("/api/health")
def health():
return _success(_health_payload())
@bp_admin.get("/api/session")
def session_info():
return _success(
{
"authenticated": _authenticated(),
"authEnabled": _settings().effective_auth_enabled,
}
)
@bp_admin.post("/api/login")
def login():
settings = _settings()
if not settings.effective_auth_enabled:
session["authenticated"] = True
session.permanent = True
return _success({"authenticated": True}, message="当前平台未启用登录保护。")
payload = _json_payload()
username = str(payload.get("username", "")).strip()
password = str(payload.get("password", "")).strip()
if username == settings.admin_username and password == settings.admin_password:
session["authenticated"] = True
session.permanent = True
session["admin_username"] = username
return _success({"authenticated": True}, message="登录成功。")
return _error("用户名或密码错误。", status=401)
@bp_admin.post("/api/logout")
def logout():
session.clear()
return _success({"authenticated": False}, message="已退出登录。")
@bp_admin.get("/api/config")
@require_auth
def config_info():
return _success(_settings().to_public_dict())
@bp_admin.get("/api/audit-events")
@require_auth
def list_audit_events():
event_type = request.args.get("eventType", "").strip()
status = request.args.get("status", "").strip()
page, page_size, offset = _pagination_params()
query = db.select(AuditEvent)
count_query = db.select(func.count()).select_from(AuditEvent)
if event_type:
query = query.where(AuditEvent.event_type == event_type)
count_query = count_query.where(AuditEvent.event_type == event_type)
if status:
query = query.where(AuditEvent.status == status)
count_query = count_query.where(AuditEvent.status == status)
total = db.session.execute(count_query).scalar_one()
page, offset, _ = _normalize_pagination(page, page_size, total)
events = db.session.execute(
query.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).offset(offset).limit(page_size)
).scalars().all()
return _success({
"events": [event.to_dict() for event in events],
**_pagination_payload(page, page_size, total),
})
@bp_admin.get("/api/codes")
@require_auth
def list_codes():
status = request.args.get("status")
page, page_size, offset = _pagination_params()
query = db.select(RedemptionCode)
count_query = db.select(func.count()).select_from(RedemptionCode)
if status in {STATUS_AVAILABLE, STATUS_PROCESSING, STATUS_USED}:
query = query.where(RedemptionCode.status == status)
count_query = count_query.where(RedemptionCode.status == status)
total = db.session.execute(count_query).scalar_one()
page, offset, _ = _normalize_pagination(page, page_size, total)
result = db.session.execute(
query.order_by(RedemptionCode.created_at.desc()).offset(offset).limit(page_size)
).scalars().all()
codes = [code.to_dict() for code in result]
return _success({"codes": codes, **_pagination_payload(page, page_size, total)})
@bp_admin.post("/api/codes/generate")
@require_auth
def generate_codes():
payload = _json_payload()
count = payload.get("count", 1)
actor = _current_actor("auth-disabled-admin")
if count < 1:
count = 1
if count > 100:
count = 100
codes = []
for _ in range(count):
code = secrets.token_urlsafe(12)
while RedemptionCode.query.filter_by(code=code).first():
code = secrets.token_urlsafe(12)
redemption_code = RedemptionCode(code=code)
db.session.add(redemption_code)
codes.append(code)
db.session.commit()
_record_audit_events(
*[
_build_audit_event(
"code_generated",
actor=actor,
code=generated_code,
details={"batchCount": len(codes)},
)
for generated_code in codes
]
)
return _success({"codes": codes, "count": len(codes)}, f"成功生成 {count} 个兑换码。")
@bp_admin.delete("/api/codes/<code>")
@require_auth
def delete_code(code: str):
actor = _current_actor("auth-disabled-admin")
redemption_code = RedemptionCode.query.filter_by(code=code).first()
if not redemption_code:
return _error("兑换码不存在。", status=404)
if redemption_code.status != STATUS_AVAILABLE:
return _error("仅可删除未使用的兑换码。", status=409)
db.session.delete(redemption_code)
db.session.commit()
_record_audit_events(
_build_audit_event(
"code_deleted",
actor=actor,
code=code,
)
)
return _success(message="兑换码已删除。")
@bp_admin.get("/api/records")
@require_auth
def list_records():
page, page_size, offset = _pagination_params()
query = db.select(RedemptionCode).where(RedemptionCode.status == STATUS_USED)
count_query = db.select(func.count()).select_from(RedemptionCode).where(RedemptionCode.status == STATUS_USED)
total = db.session.execute(count_query).scalar_one()
page, offset, _ = _normalize_pagination(page, page_size, total)
records = db.session.execute(
query.order_by(RedemptionCode.used_at.desc()).offset(offset).limit(page_size)
).scalars().all()
return _success({
"records": [code.to_dict() for code in records],
**_pagination_payload(page, page_size, total),
})
@bp_user.get("/")
def index():
return render_template("user_redemption.html", settings=_settings())
@bp_user.get("/api/health")
def user_health():
return _success(_health_payload())
@bp_user.post("/api/redeem")
def redeem():
payload = _json_payload()
code = str(payload.get("code", "")).strip().upper()
username = str(payload.get("username", "")).strip().lower()
actor = _current_actor("public")
if not code:
return _error("请输入兑换码。", status=400)
if not username:
return _error("请输入用户名。", status=400)
if not _reserve_code(code):
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="failed",
actor=actor,
code=code,
username=username,
details={"message": "兑换码无效或已被使用。"},
)
)
return _error("兑换码无效或已被使用。", status=404)
try:
user_result = _service().create_user(username=username)
except ServiceConfigurationError as exc:
_release_code(code)
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="failed",
actor=actor,
code=code,
username=username,
details={"message": str(exc)},
)
)
return _error(str(exc), status=503)
except ServiceOperationError as exc:
_release_code(code)
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="failed",
actor=actor,
code=code,
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("兑换码 %s 开通账号时发生未预期错误", code)
_release_code(code)
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="failed",
actor=actor,
code=code,
username=username,
details={"message": f"创建账号失败: {exc}"},
)
)
return _error(f"创建账号失败: {exc}", status=500)
if not _complete_redemption(code, username, user_result.get("userPrincipalName")):
logger.error("账号 %s 已创建,但兑换码 %s 未能完成最终状态更新。", user_result.get("userPrincipalName"), code)
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="warning",
actor=actor,
code=code,
username=username,
principal_name=user_result.get("userPrincipalName"),
details={"message": "账号已创建,但兑换码状态更新失败。"},
)
)
return _error(
"账号已创建,但兑换码状态更新失败,请联系管理员处理。",
status=500,
details={"userPrincipalName": user_result.get("userPrincipalName")},
)
_record_audit_events(
_build_audit_event(
"redeem_completed",
status="success",
actor=actor,
code=code,
username=username,
principal_name=user_result.get("userPrincipalName"),
details={
"licenseAssigned": user_result.get("licenseAssigned"),
"licenseMessage": user_result.get("licenseMessage"),
},
)
)
return _success({
"userPrincipalName": user_result.get("userPrincipalName"),
"temporaryPassword": user_result.get("temporaryPassword"),
"licenseAssigned": user_result.get("licenseAssigned"),
"licenseMessage": user_result.get("licenseMessage"),
}, "账号开通成功!", status=201)
@bp_user.get("/api/config")
def config():
return _success(_settings().to_public_dict())