diff --git a/app.py b/app.py index f147f53..e1b25df 100644 --- a/app.py +++ b/app.py @@ -2,13 +2,16 @@ from __future__ import annotations import json import re +import secrets +from urllib.parse import urlencode, urlparse from typing import Any +import requests from flask import Flask, jsonify, redirect, render_template, request, session, url_for from flask_socketio import SocketIO, emit, join_room as sio_join_room from werkzeug.security import check_password_hash, generate_password_hash -from config import Config +from config import Config, DEFAULT_SETTINGS, SETTINGS_FILE from game.action_resolver import ActionResolver from game.room_manager import RoomManager from game.state_manager import StateManager @@ -416,6 +419,80 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: app.extensions["story_engine"] = story_engine app.extensions["room_manager"] = room_manager + def load_runtime_settings() -> dict[str, Any]: + defaults = dict(DEFAULT_SETTINGS) + defaults["linuxdo_oauth"] = dict(DEFAULT_SETTINGS.get("linuxdo_oauth", {})) + try: + payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return defaults + if not isinstance(payload, dict): + return defaults + + merged = dict(defaults) + merged.update(payload) + raw_oauth = payload.get("linuxdo_oauth", {}) + if isinstance(raw_oauth, dict): + merged["linuxdo_oauth"] = {**defaults["linuxdo_oauth"], **raw_oauth} + else: + merged["linuxdo_oauth"] = dict(defaults["linuxdo_oauth"]) + return merged + + def save_runtime_settings(settings_payload: dict[str, Any]) -> None: + SETTINGS_FILE.write_text( + json.dumps(settings_payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _is_valid_http_url(url_text: Any) -> bool: + url = str(url_text or "").strip() + if not url: + return False + parsed = urlparse(url) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + def get_linuxdo_oauth_config(*, mask_secret: bool = False) -> dict[str, Any]: + defaults = DEFAULT_SETTINGS.get("linuxdo_oauth", {}) + runtime = load_runtime_settings().get("linuxdo_oauth", {}) + raw = runtime if isinstance(runtime, dict) else {} + cfg = { + "enabled": bool(raw.get("enabled", defaults.get("enabled", False))), + "client_id": str(raw.get("client_id", defaults.get("client_id", ""))).strip(), + "client_secret": str(raw.get("client_secret", defaults.get("client_secret", ""))).strip(), + "authorize_url": str(raw.get("authorize_url", defaults.get("authorize_url", ""))).strip(), + "token_url": str(raw.get("token_url", defaults.get("token_url", ""))).strip(), + "userinfo_url": str(raw.get("userinfo_url", defaults.get("userinfo_url", ""))).strip(), + "scope": str(raw.get("scope", defaults.get("scope", ""))).strip(), + } + if mask_secret: + secret = cfg["client_secret"] + cfg["client_secret"] = "" if not secret else ("*" * 8) + cfg["client_secret_configured"] = bool(secret) + return cfg + + def oauth_error_redirect(message: str) -> Any: + return redirect(url_for("login_page", oauth_error=message)) + + def resolve_linuxdo_identity(userinfo: dict[str, Any]) -> tuple[str, str]: + raw_id = userinfo.get("id") + if raw_id in {None, ""} and isinstance(userinfo.get("user"), dict): + raw_id = userinfo["user"].get("id") + if raw_id in {None, ""}: + raise ValueError("missing_user_id") + + safe_id = re.sub(r"[^a-z0-9_]+", "_", str(raw_id).strip().lower()).strip("_") or "user" + username = f"linuxdo_{safe_id}"[:32] + if len(username) < 3: + username = "linuxdo_user" + + display_name = str( + userinfo.get("name") + or userinfo.get("username") + or (userinfo.get("user", {}) if isinstance(userinfo.get("user"), dict) else {}).get("username") + or username + ).strip() + return username, (display_name[:24] or username) + def current_user() -> dict[str, Any] | None: user_id = session.get("user_id") if not user_id: @@ -476,7 +553,8 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: @app.get("/login") def login_page() -> str: - return render_template("login.html", user=serialize_user(current_user())) + oauth_error = str(request.args.get("oauth_error", "")).strip()[:120] + return render_template("login.html", user=serialize_user(current_user()), oauth_error=oauth_error) @app.get("/register") def register_page() -> str: @@ -531,6 +609,103 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: session.pop("user_id", None) return jsonify({"ok": True}) + @app.get("/api/auth/oauth/linuxdo/start") + def auth_oauth_linuxdo_start_api() -> Any: + cfg = get_linuxdo_oauth_config() + if not cfg.get("enabled"): + return jsonify({"ok": False, "error": "Linux DO OAuth 未启用。"}), 400 + required_fields = ["client_id", "client_secret", "authorize_url", "token_url", "userinfo_url"] + missing = [field for field in required_fields if not str(cfg.get(field, "")).strip()] + if missing: + return jsonify({"ok": False, "error": f"Linux DO OAuth 配置缺失:{', '.join(missing)}"}), 400 + for url_field in ("authorize_url", "token_url", "userinfo_url"): + if not _is_valid_http_url(cfg.get(url_field, "")): + return jsonify({"ok": False, "error": f"Linux DO OAuth 配置无效:{url_field}"}), 400 + + state = secrets.token_urlsafe(24) + session["linuxdo_oauth_state"] = state + callback_url = url_for("auth_oauth_linuxdo_callback_api", _external=True) + query = { + "client_id": cfg["client_id"], + "response_type": "code", + "redirect_uri": callback_url, + "state": state, + } + scope = str(cfg.get("scope", "")).strip() + if scope: + query["scope"] = scope + authorize_url = f"{cfg['authorize_url']}?{urlencode(query)}" + return redirect(authorize_url) + + @app.get("/api/auth/oauth/linuxdo/callback") + def auth_oauth_linuxdo_callback_api() -> Any: + cfg = get_linuxdo_oauth_config() + if not cfg.get("enabled"): + return oauth_error_redirect("Linux DO OAuth 未启用") + + state = str(request.args.get("state", "")).strip() + code = str(request.args.get("code", "")).strip() + expected_state = str(session.pop("linuxdo_oauth_state", "")).strip() + if not code: + return oauth_error_redirect("缺少授权 code") + if not state or state != expected_state: + return oauth_error_redirect("state 校验失败") + + callback_url = url_for("auth_oauth_linuxdo_callback_api", _external=True) + try: + token_resp = requests.post( + cfg["token_url"], + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": cfg["client_id"], + "client_secret": cfg["client_secret"], + "redirect_uri": callback_url, + }, + timeout=12, + ) + token_resp.raise_for_status() + token_payload = token_resp.json() + except Exception as exc: + return oauth_error_redirect(f"获取 access_token 失败: {exc}") + + access_token = str(token_payload.get("access_token", "")).strip() + if not access_token: + return oauth_error_redirect("access_token 为空") + + try: + userinfo_resp = requests.get( + cfg["userinfo_url"], + headers={"Authorization": f"Bearer {access_token}"}, + timeout=12, + ) + userinfo_resp.raise_for_status() + userinfo = userinfo_resp.json() + if not isinstance(userinfo, dict): + return oauth_error_redirect("userinfo 响应格式无效") + except Exception as exc: + return oauth_error_redirect(f"获取用户信息失败: {exc}") + + try: + username, display_name = resolve_linuxdo_identity(userinfo) + except ValueError: + return oauth_error_redirect("用户信息缺少 id") + + user = persistence.get_user_by_username(username) + if user is None: + random_password = secrets.token_urlsafe(32) + try: + user = persistence.create_user( + username=username, + password_hash=generate_password_hash(random_password, method="pbkdf2:sha256"), + display_name=display_name, + ) + except ValueError as exc: + return oauth_error_redirect(f"创建本地账号失败: {readable_error(str(exc))}") + + session["user_id"] = user["user_id"] + return redirect(url_for("index")) + @app.get("/api/me") def me_api() -> Any: user = current_user() @@ -875,6 +1050,73 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: card["creator_display_name"] = creator["username"] if creator else ("官方" if card.get("is_official") else "玩家") return jsonify({"ok": True, "cards": cards}) + @app.get("/api/admin/settings/oauth/linuxdo") + def admin_linuxdo_oauth_get_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + return jsonify({"ok": True, "config": get_linuxdo_oauth_config(mask_secret=True)}) + + @app.patch("/api/admin/settings/oauth/linuxdo") + def admin_linuxdo_oauth_patch_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + + payload = request.get_json(silent=True) or {} + if not isinstance(payload, dict): + payload = {} + + try: + raw_settings = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + raw_settings = {} + if not isinstance(raw_settings, dict): + raw_settings = {} + + defaults = DEFAULT_SETTINGS.get("linuxdo_oauth", {}) + existing_raw = raw_settings.get("linuxdo_oauth", {}) + existing_cfg = {**defaults, **(existing_raw if isinstance(existing_raw, dict) else {})} + + enabled = bool(payload.get("enabled", existing_cfg.get("enabled", False))) + client_id = str(payload.get("client_id", existing_cfg.get("client_id", ""))).strip() + incoming_secret = payload.get("client_secret") + if incoming_secret is None: + client_secret = str(existing_cfg.get("client_secret", "")).strip() + else: + normalized_secret = str(incoming_secret).strip() + client_secret = normalized_secret or str(existing_cfg.get("client_secret", "")).strip() + authorize_url = str(payload.get("authorize_url", existing_cfg.get("authorize_url", ""))).strip() + token_url = str(payload.get("token_url", existing_cfg.get("token_url", ""))).strip() + userinfo_url = str(payload.get("userinfo_url", existing_cfg.get("userinfo_url", ""))).strip() + scope = str(payload.get("scope", existing_cfg.get("scope", ""))).strip() + + for field, url_value in ( + ("authorize_url", authorize_url), + ("token_url", token_url), + ("userinfo_url", userinfo_url), + ): + if not _is_valid_http_url(url_value): + return jsonify({"ok": False, "error": f"Linux DO OAuth 配置无效:{field}"}), 400 + + if enabled and not client_id: + return jsonify({"ok": False, "error": "Linux DO OAuth 配置缺失:client_id"}), 400 + if enabled and not client_secret: + return jsonify({"ok": False, "error": "Linux DO OAuth 配置缺失:client_secret"}), 400 + + raw_settings["linuxdo_oauth"] = { + "enabled": enabled, + "client_id": client_id, + "client_secret": client_secret, + "authorize_url": authorize_url, + "token_url": token_url, + "userinfo_url": userinfo_url, + "scope": scope, + } + save_runtime_settings(raw_settings) + + return jsonify({"ok": True, "config": get_linuxdo_oauth_config(mask_secret=True)}) + @app.patch("/api/admin/cards/") def admin_patch_card_api(card_id: str) -> Any: _, error_resp = require_admin_json() diff --git a/config.py b/config.py index 1d0f2f0..e5eaac2 100644 --- a/config.py +++ b/config.py @@ -22,6 +22,15 @@ DEFAULT_SETTINGS: dict[str, Any] = { "app_host": "0.0.0.0", "app_port": 8095, "debug": True, + "linuxdo_oauth": { + "enabled": False, + "client_id": "", + "client_secret": "", + "authorize_url": "https://connect.linux.do/oauth2/authorize", + "token_url": "https://connect.linux.do/oauth2/token", + "userinfo_url": "https://connect.linux.do/api/user", + "scope": "read", + }, } @@ -49,6 +58,12 @@ def _load_settings() -> dict[str, Any]: merged = dict(DEFAULT_SETTINGS) merged.update(payload) + linuxdo_defaults = DEFAULT_SETTINGS["linuxdo_oauth"] + linuxdo_payload = payload.get("linuxdo_oauth", {}) + if isinstance(linuxdo_payload, dict): + merged["linuxdo_oauth"] = {**linuxdo_defaults, **linuxdo_payload} + else: + merged["linuxdo_oauth"] = dict(linuxdo_defaults) return merged @@ -82,6 +97,7 @@ class Config: APP_HOST = str(SETTINGS.get("app_host", DEFAULT_SETTINGS["app_host"])) APP_PORT = int(SETTINGS.get("app_port", DEFAULT_SETTINGS["app_port"])) DEBUG = bool(SETTINGS.get("debug", DEFAULT_SETTINGS["debug"])) + LINUXDO_OAUTH = SETTINGS.get("linuxdo_oauth", DEFAULT_SETTINGS["linuxdo_oauth"]) class TestConfig(Config): diff --git a/settings.json b/settings.json index c50aa5e..0ad79ee 100644 --- a/settings.json +++ b/settings.json @@ -12,5 +12,14 @@ "max_action_length": 280, "app_host": "0.0.0.0", "app_port": 8095, - "debug": true + "debug": true, + "linuxdo_oauth": { + "enabled": false, + "client_id": "", + "client_secret": "", + "authorize_url": "https://connect.linux.do/oauth2/authorize", + "token_url": "https://connect.linux.do/oauth2/token", + "userinfo_url": "https://connect.linux.do/api/user", + "scope": "read" + } } diff --git a/static/js/admin.js b/static/js/admin.js index d11b9ac..de2857b 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -12,6 +12,7 @@ const usersTable = document.getElementById('users-table'); const roomsTable = document.getElementById('rooms-table'); const cardsTable = document.getElementById('cards-table'); + const oauthForm = document.getElementById('linuxdo-oauth-form'); const ROLE_OPTIONS = ['player', 'moderator', 'admin']; @@ -252,6 +253,54 @@ cardsTable.appendChild(table); }; + const loadLinuxdoOauth = async () => { + if (!oauthForm) return true; + const data = await getJson('/api/admin/settings/oauth/linuxdo'); + if (!data.ok) { + setStatus(data.error || '加载 OAuth 配置失败'); + return false; + } + const cfg = data.config || {}; + document.getElementById('linuxdo-enabled').value = cfg.enabled ? 'true' : 'false'; + document.getElementById('linuxdo-client-id').value = cfg.client_id || ''; + document.getElementById('linuxdo-client-secret').value = ''; + document.getElementById('linuxdo-authorize-url').value = cfg.authorize_url || ''; + document.getElementById('linuxdo-token-url').value = cfg.token_url || ''; + document.getElementById('linuxdo-userinfo-url').value = cfg.userinfo_url || ''; + document.getElementById('linuxdo-scope').value = cfg.scope || ''; + return true; + }; + + if (oauthForm) { + oauthForm.addEventListener('submit', async (event) => { + event.preventDefault(); + setStatus('正在保存 OAuth 配置...'); + const payload = { + enabled: document.getElementById('linuxdo-enabled').value === 'true', + client_id: document.getElementById('linuxdo-client-id').value.trim(), + client_secret: document.getElementById('linuxdo-client-secret').value, + authorize_url: document.getElementById('linuxdo-authorize-url').value.trim(), + token_url: document.getElementById('linuxdo-token-url').value.trim(), + userinfo_url: document.getElementById('linuxdo-userinfo-url').value.trim(), + scope: document.getElementById('linuxdo-scope').value.trim(), + }; + if (!payload.client_secret) { + delete payload.client_secret; + } + try { + const data = await patchJson('/api/admin/settings/oauth/linuxdo', payload); + if (!data.ok) { + setStatus(data.error || '保存失败'); + return; + } + document.getElementById('linuxdo-client-secret').value = ''; + setStatus('OAuth 配置已保存。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } + const loadAll = async () => { if (!user || user.role !== 'admin') { window.location.href = '/'; @@ -259,12 +308,13 @@ } setStatus('正在加载后台数据...'); try { - const [usersResp, roomsResp, cardsResp] = await Promise.all([ + const [usersResp, roomsResp, cardsResp, oauthOk] = await Promise.all([ getJson('/api/admin/users'), getJson('/api/admin/rooms'), getJson('/api/admin/cards'), + loadLinuxdoOauth(), ]); - if (!usersResp.ok || !roomsResp.ok || !cardsResp.ok) { + if (!usersResp.ok || !roomsResp.ok || !cardsResp.ok || !oauthOk) { const msg = usersResp.error || roomsResp.error || cardsResp.error || '加载失败'; setStatus(msg); return; diff --git a/templates/admin.html b/templates/admin.html index ecbd725..4fe68cb 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -21,6 +21,37 @@
+
+

Linux DO OAuth 配置

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+

用户权限管理

diff --git a/templates/login.html b/templates/login.html index 14e85cc..7f1c7f9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -16,9 +16,10 @@ + 使用 Linux DO 登录 -
+
{{ oauth_error or '' }}