fix: harden google drive import auth
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-06-27 20:31:34 +08:00
parent 90e44a8f8a
commit f3e412cd21
8 changed files with 301 additions and 101 deletions

View File

@@ -32,11 +32,21 @@ TOKEN_FILE = os.getenv('GOOGLE_DRIVE_TOKEN_FILE', 'config/google_token.json')
_LEGACY_PICKLE_FILE = os.getenv('GOOGLE_DRIVE_LEGACY_PICKLE_FILE', 'config/google_token.pickle')
INTERACTIVE_AUTH_ENV = 'GOOGLE_DRIVE_ALLOW_INTERACTIVE_AUTH'
INTERACTIVE_AUTH_TIMEOUT_ENV = 'GOOGLE_DRIVE_INTERACTIVE_AUTH_TIMEOUT_SECONDS'
NONINTERACTIVE_RUNTIME_ENV = 'GOOGLE_DRIVE_NONINTERACTIVE_RUNTIME'
def _interactive_auth_allowed() -> bool:
"""Background jobs must not try to open a browser inside containers."""
return os.getenv(INTERACTIVE_AUTH_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
requested = os.getenv(INTERACTIVE_AUTH_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
if not requested:
return False
noninteractive = (
os.getenv(NONINTERACTIVE_RUNTIME_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
or os.getenv("FLASK_ENV", "").strip().lower() == "production"
or os.getenv("KUBERNETES_SERVICE_HOST")
or os.path.exists("/.dockerenv")
)
return not noninteractive
def _interactive_auth_timeout_seconds() -> int:
@@ -89,6 +99,92 @@ class GoogleDriveService:
logger.error(error_message)
return False
def check_auth_readiness(self, refresh_expired: bool = True) -> Dict[str, Any]:
"""Validate persisted Google Drive credentials without starting browser OAuth."""
self._clear_error()
status = {
"ready": False,
"kind": None,
"message": "",
"credentials_file_exists": os.path.exists(CREDENTIALS_FILE),
"token_file_exists": os.path.exists(TOKEN_FILE),
"token_file_writable": os.access(TOKEN_FILE, os.W_OK) if os.path.exists(TOKEN_FILE) else False,
"token_dir_writable": os.access(os.path.dirname(TOKEN_FILE) or ".", os.W_OK),
"has_refresh_token": False,
"interactive_auth_allowed": _interactive_auth_allowed(),
}
if not status["credentials_file_exists"]:
status.update({
"kind": "credentials_missing",
"message": "找不到 Google Drive 憑證檔,請確認雲端匯入設定。"
})
self._set_error(status["kind"], status["message"])
return status
if not status["token_file_exists"]:
status.update({
"kind": "reauthorization_required",
"message": "Google Drive 授權檔不存在;背景排程不可啟動瀏覽器,請先提供 config/google_token.json。"
})
self._set_error(status["kind"], status["message"])
return status
try:
with open(TOKEN_FILE, 'r') as token:
token_data = json.load(token)
self.credentials = Credentials.from_authorized_user_info(token_data, SCOPES)
status["has_refresh_token"] = bool(getattr(self.credentials, "refresh_token", None))
except Exception as exc:
status.update({
"kind": "invalid_token_file",
"message": "Google Drive 授權檔無法讀取;請重新提供有效的 JSON 授權檔。"
})
self._set_error(status["kind"], f"{status['message']} 原始錯誤:{exc}")
return status
if not status["token_file_writable"] or not status["token_dir_writable"]:
status.update({
"kind": "token_store_failed",
"message": "Google Drive 授權檔或設定目錄不可寫,重啟或刷新後可能再次失效。"
})
self._set_error(status["kind"], status["message"])
return status
if self.credentials and self.credentials.valid:
status.update({"ready": True, "kind": "ready", "message": "Google Drive 授權可用。"})
self._clear_error()
return status
if self.credentials and self.credentials.expired and self.credentials.refresh_token:
if not refresh_expired:
status.update({"kind": "token_expired", "message": "Google Drive 授權已過期,等待刷新。"})
self._set_error(status["kind"], status["message"])
return status
try:
logger.info("刷新 Google Drive token...")
self.credentials.refresh(Request())
except Exception as exc:
status.update({
"kind": "reauthorization_required",
"message": "Google Drive refresh token 已失效;請重新完成雲端授權。"
})
self._set_error(status["kind"], f"{status['message']} 原始錯誤:{exc}")
return status
if not self._save_credentials():
status.update({"kind": self.last_error_kind, "message": self.last_error or ""})
return status
status.update({"ready": True, "kind": "ready", "message": "Google Drive 授權已刷新並可用。"})
self._clear_error()
return status
status.update({
"kind": "reauthorization_required",
"message": "Google Drive 授權缺少可刷新憑證;背景排程不可啟動瀏覽器,請重新提供 config/google_token.json。"
})
self._set_error(status["kind"], status["message"])
return status
@staticmethod
def _escape_query_value(value: str) -> str:
return value.replace("\\", "\\\\").replace("'", "\\'")
@@ -109,56 +205,32 @@ class GoogleDriveService:
"請重新執行認證流程以產生新 token舊 pickle 檔案不會被自動刪除。"
)
# 檢查是否已有 token
if os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE, 'r') as token:
token_data = json.load(token)
self.credentials = Credentials.from_authorized_user_info(token_data, SCOPES)
# 如果沒有有效憑證,進行認證流程
if not self.credentials or not self.credentials.valid:
if self.credentials and self.credentials.expired and self.credentials.refresh_token:
# 嘗試刷新 token
logger.info("刷新 Google Drive token...")
self.credentials.refresh(Request())
else:
# 需要重新認證
if not os.path.exists(CREDENTIALS_FILE):
error_message = f"找不到認證檔案: {CREDENTIALS_FILE}"
self._set_error("authentication_failed", error_message)
logger.error(error_message)
return False
if not _interactive_auth_allowed():
if os.path.exists(_LEGACY_PICKLE_FILE) and not os.path.exists(TOKEN_FILE):
error_message = (
"偵測到舊版 Google Drive 授權檔 config/google_token.pickle"
"但正式排程只讀 config/google_token.json。請先執行一次性授權檔轉換"
"再讓自動匯入任務重跑。"
)
else:
error_message = (
"Google Drive 需要重新授權,但背景排程不可啟動瀏覽器。"
"請在可互動環境完成 OAuth或提供 config/google_token.json 後再重跑。"
)
self._set_error("reauthorization_required", error_message)
logger.error(error_message)
return False
logger.info("進行 Google Drive 認證...")
flow = InstalledAppFlow.from_client_secrets_file(
CREDENTIALS_FILE, SCOPES
)
# 即使是人工授權,也只印授權 URL不在伺服器/容器內自動尋找瀏覽器。
self.credentials = flow.run_local_server(
open_browser=False,
timeout_seconds=_interactive_auth_timeout_seconds(),
authorization_prompt_message=(
"請在可登入 Google 的瀏覽器開啟以下網址完成授權:{url}"
),
readiness = self.check_auth_readiness(refresh_expired=True)
if not readiness.get("ready"):
if os.path.exists(_LEGACY_PICKLE_FILE) and not os.path.exists(TOKEN_FILE):
error_message = (
"偵測到舊版 Google Drive 授權檔 config/google_token.pickle"
"但正式排程只讀 config/google_token.json。請先執行一次性授權檔轉換"
"再讓自動匯入任務重跑。"
)
self._set_error("reauthorization_required", error_message)
logger.error(error_message)
return False
if not _interactive_auth_allowed():
logger.error(readiness.get("message") or "Google Drive 授權不可用。")
return False
logger.info("進行 Google Drive 認證...")
flow = InstalledAppFlow.from_client_secrets_file(CREDENTIALS_FILE, SCOPES)
self.credentials = flow.run_local_server(
open_browser=False,
timeout_seconds=_interactive_auth_timeout_seconds(),
authorization_prompt_message=(
"請在可登入 Google 的瀏覽器開啟以下網址完成授權:{url}"
),
)
# 儲存憑證供下次使用JSON 格式,安全無 RCE 風險)
if not self._save_credentials():
return False

View File

@@ -56,9 +56,15 @@ def humanize_import_error(message: Any) -> str:
return ""
lowered = raw.lower()
if "could not locate runnable browser" in lowered or "reauthorization_required" in lowered:
if (
"could not locate runnable browser" in lowered
or "reauthorization_required" in lowered
or "授權檔不存在" in raw
or "缺少可刷新" in raw
or "refresh token" in lowered
):
return "Google Drive 授權需要重新確認;請重新完成雲端授權後再執行匯入。"
if "token_store_failed" in lowered or ("permission" in lowered and "google" in lowered):
if "token_store_failed" in lowered or "不可寫" in raw or ("permission" in lowered and "google" in lowered):
return "Google Drive 授權無法穩定保存;請通知維護人員確認主機授權檔權限,避免重啟後再次失效。"
if "google drive" in lowered or "credentials" in lowered or "authenticate" in lowered or "token" in lowered:
return "Google Drive 連線或授權異常;請確認雲端資料夾權限後再執行匯入。"