From 6110badd7a0d855c27c0c9398184b58e8a6a1619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Wed, 25 Feb 2026 08:29:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=20oauth=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=B0=20controllers=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 2 个 oauth 相关 API 到 controllers/oauth.py: - api_get_oauth_auth_url - api_exchange_oauth_token - 更新 routes/oauth.py 移除 impl 参数 - OAuth 配置常量从 config 模块读取 Co-Authored-By: Claude Opus 4.6 --- outlook_web/app.py | 2 +- outlook_web/controllers/oauth.py | 121 +++++++++++++++++++++++++++++++ outlook_web/routes/oauth.py | 9 ++- 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 outlook_web/controllers/oauth.py diff --git a/outlook_web/app.py b/outlook_web/app.py index a883f39..b1f12e5 100644 --- a/outlook_web/app.py +++ b/outlook_web/app.py @@ -75,7 +75,7 @@ def create_app(*, autostart_scheduler: Optional[bool] = None): app.register_blueprint(accounts.create_blueprint(impl=legacy)) app.register_blueprint(emails.create_blueprint(impl=legacy)) app.register_blueprint(temp_emails.create_blueprint(impl=legacy)) - app.register_blueprint(oauth.create_blueprint(impl=legacy)) + app.register_blueprint(oauth.create_blueprint()) app.register_blueprint(settings.create_blueprint()) app.register_blueprint(scheduler.create_blueprint()) app.register_blueprint(system.create_blueprint()) diff --git a/outlook_web/controllers/oauth.py b/outlook_web/controllers/oauth.py new file mode 100644 index 0000000..394f4f4 --- /dev/null +++ b/outlook_web/controllers/oauth.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import urllib.parse +from typing import Any + +import requests +from flask import jsonify, request + +from outlook_web import config +from outlook_web.audit import log_audit +from outlook_web.security.auth import ( + check_export_verify_token, + consume_export_verify_token, + login_required, +) + + +# OAuth 配置 +OAUTH_SCOPES = [ + "offline_access", + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/User.Read" +] + + +# ==================== OAuth API ==================== + + +@login_required +def api_get_oauth_auth_url() -> Any: + """生成 OAuth 授权 URL""" + oauth_client_id = config.get_oauth_client_id() + oauth_redirect_uri = config.get_oauth_redirect_uri() + + base_auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + params = { + "client_id": oauth_client_id, + "response_type": "code", + "redirect_uri": oauth_redirect_uri, + "response_mode": "query", + "scope": " ".join(OAUTH_SCOPES), + "state": "12345" + } + auth_url = f"{base_auth_url}?{urllib.parse.urlencode(params)}" + + return jsonify({ + 'success': True, + 'auth_url': auth_url, + 'client_id': oauth_client_id, + 'redirect_uri': oauth_redirect_uri + }) + + +@login_required +def api_exchange_oauth_token() -> Any: + """使用授权码换取 Refresh Token""" + oauth_client_id = config.get_oauth_client_id() + oauth_redirect_uri = config.get_oauth_redirect_uri() + + data = request.json + redirected_url = data.get('redirected_url', '').strip() + verify_token = data.get('verify_token') + + if not redirected_url: + return jsonify({'success': False, 'error': '请提供授权后的完整 URL'}) + + # 从 URL 中提取 code + try: + parsed_url = urllib.parse.urlparse(redirected_url) + query_params = urllib.parse.parse_qs(parsed_url.query) + auth_code = query_params['code'][0] + except (KeyError, IndexError): + return jsonify({'success': False, 'error': '无法从 URL 中提取授权码,请检查 URL 是否正确'}) + + # 二次验证(敏感信息:refresh_token 不默认明文返回) + ok, error_message = check_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + # 使用 Code 换取 Token (Public Client 不需要 client_secret) + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" + token_data = { + "client_id": oauth_client_id, + "code": auth_code, + "redirect_uri": oauth_redirect_uri, + "grant_type": "authorization_code", + "scope": " ".join(OAUTH_SCOPES) + } + + try: + response = requests.post(token_url, data=token_data, timeout=30) + except Exception as e: + return jsonify({'success': False, 'error': f'请求失败: {str(e)}'}) + + if response.status_code == 200: + tokens = response.json() + refresh_token = tokens.get('refresh_token') + + if not refresh_token: + return jsonify({'success': False, 'error': '未能获取 Refresh Token'}) + + # 成功后再消费一次性验证 token(避免失败时消耗 token) + ok, error_message = consume_export_verify_token(verify_token) + if not ok: + return jsonify({'success': False, 'error': error_message, 'need_verify': True}), 401 + + log_audit('oauth_exchange', 'oauth', None, "换取 Refresh Token 成功(已二次验证)") + + return jsonify({ + 'success': True, + 'refresh_token': refresh_token, + 'client_id': oauth_client_id, + 'token_type': tokens.get('token_type'), + 'expires_in': tokens.get('expires_in'), + 'scope': tokens.get('scope') + }) + else: + error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {} + error_msg = error_data.get('error_description', response.text) + return jsonify({'success': False, 'error': f'获取令牌失败: {error_msg}'}) diff --git a/outlook_web/routes/oauth.py b/outlook_web/routes/oauth.py index 0d7bb40..c5c785a 100644 --- a/outlook_web/routes/oauth.py +++ b/outlook_web/routes/oauth.py @@ -2,10 +2,13 @@ from __future__ import annotations from flask import Blueprint +from outlook_web.controllers import oauth as oauth_controller -def create_blueprint(*, impl) -> Blueprint: + +def create_blueprint() -> Blueprint: + """创建 oauth Blueprint""" bp = Blueprint("oauth", __name__) - bp.add_url_rule("/api/oauth/auth-url", view_func=impl.api_get_oauth_auth_url, methods=["GET"]) - bp.add_url_rule("/api/oauth/exchange-token", view_func=impl.api_exchange_oauth_token, methods=["POST"]) + bp.add_url_rule("/api/oauth/auth-url", view_func=oauth_controller.api_get_oauth_auth_url, methods=["GET"]) + bp.add_url_rule("/api/oauth/exchange-token", view_func=oauth_controller.api_exchange_oauth_token, methods=["POST"]) return bp