fix: harden google drive import auth
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 連線或授權異常;請確認雲端資料夾權限後再執行匯入。"
|
||||
|
||||
Reference in New Issue
Block a user