又來搞認證了,久久搞一次,每次都卡關,記得上一次做 web 前端也弄很久。
認證,或指廣泛的關於安全性的議題,超級重要的,但在小咖咖階段就還好,沒有人會想來攻擊我,沒有肉啊。但當軟體要給別人用時,就不一樣了,因為客人會在意。我現在的系統,以富邦帳號的憑證為入口,作為 MVP 無可厚非,但長遠來看,功能介面若要做到 app 與網頁端兩邊整合,那就還要一個識別機制,就是桌面端也需要 google login。
在 Desktop App 使用 Google Login
Desktop App 的執行環境,與 Web 是截然不同的,而 google auth 相關服務的底層,都是透過 REST-ful api 提供的,兩者之間存在巨大的鴻溝。但這些厲害的軟體巨頭,已經提供了相當完善的工具,也已經做出最佳實踐範例,讓我們很有感。就如使用 vscode 時需要調用 azure 資源,在 command line 打 az login 就會跳出系統預設瀏覽器,完成帳號密碼或更複雜的認證程序後,會轉址到一個本地端網址,像這樣:
整個過程不會把帳號密碼暴露在任何非 azure 管控的介面,本地端完全不經手和儲存,使用者這些敏感資料。那 login 成功後的結果如何接收?其實流程有點複雜:必須先啟動一個本地端的 web server (如上圖所示),監控 server event 等候接受 azure 在完成認證後的轉址呼叫,接收到後取出 access token,再去換取真正含有 user 資料的 id token,然後這個 id token 就可以作為後續跟 server 溝通的憑藉。此 token 預設是一小時後就失效的,因此是比較安全的,本地的 app 可以據此控管執行權限。以上這些流程都在一瞬間就完成了,真是厲害且有趣,這次在我的 app 中就是要實作整個流程。
話說現在幾乎所有的專業門檻,都被 ai 給弭平了!現在的 coding 更多的工作是問對關鍵的問題。在經過 gemini 把我帶入迷霧森林後,chatgpt 竟然給出超大絕招,三行搞定!以下就是我歸納出的最精簡寫法:
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport import requests
from google.oauth2 import id_token
flow = InstalledAppFlow.from_client_secrets_file(
CLIENT_SECRET_FILE, SCOPES)
creds = flow.run_local_server(port=0) # 0 表示由系統自動選擇閒置的 port
idinfo = id_token.verify_oauth2_token(creds.id_token,
requests.Request(), CLIENT_ID)
在認真理解過流程細節後,體驗到這些 library 的強大。但有一些準備工作非常關鍵,就是 CLIENT_SECRET_FILE。這必須在 google cloud 管理介面完成:


注意要選「電腦版應用程式」,經測試選錯是不能用的。憑證建立時,有提供下載 json file 的選項,只有一次機會,建立完成後無法再回來下載,只能刪掉重建。
以上主要流程已經完成,但若要讓使用者流程更順暢,可以在 login 完成後,把 credential 存下來:creds.to_json() 可以轉成文字檔。然後每次需要檢查身份時,先從檔案載入:creds = Credentials.from_authorized_user_file(file_path, SCOPES)。然後就又遇到一個坑了:這個 creds 並不含 id_token!
所以,存檔時最好也把 id_token 也存起來,這樣流程變成:
- 檢查 id_token 檔案,若存在先載入並驗證,若驗證失敗,執行下一步。
- 檢查 credential,若存在,載入並執行 creds.refresh(requests.Request()) 可得新的 id_token。若有任何錯誤,再重新啟動最原始的認證流程。
到此 desktop app 的 login 流程,已經完善。
Client_Secret_File 的安全性問題
Desktop App 使用這個檔案,向 google cloud 所設定的專案,請求啟動認證程序並取得使用者資料,其中明定取得的資料範圍,只有 email and openid,無法異動專案資源。所以我選擇將它包在 client 端的某處,安全性應該還好,雖然我的 ai 助理不斷告誡必須保護好。倒是先前存取 Firestore Database 所用的 serviceAccountKey.json,就真的有疑慮了,因為使用它可以隨意的操作我的資料庫!解法很直覺,就是把資料庫的動作包在 fastapi 裡面,全部移到後端去,只開放 http 端點供呼叫,且要驗證 id_token。如此一來,系統應該具備基本的安全性。
完整程式碼:
import logging
import threading
import os
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport import requests
from google.oauth2 import id_token
from google.oauth2.credentials import Credentials
from utils.file import get_project_root, resource_path
CLIENT_ID = "123456789012-xxx.apps.googleusercontent.com"
CLIENT_SECRET_FILE = "client_secret_123456789012-xxx.apps.googleusercontent.com.json"
SCOPES = ["openid", "https://www.googleapis.com/auth/userinfo.email"]
LOCAL_CREDENTIAL_FILE = "credential.json"
LOCAL_ID_TOKEN_FILE = "idtoken.txt"
class LoginService:
def __init__(self):
self.logger = logging.getLogger(__name__)
def start_google_login(self):
idinfo = self.check_existing_id_token()
if idinfo == None:
idinfo = self.check_existing_credential()
if idinfo != None:
self.logger.info(f"User {idinfo['email']} is already logged in.")
else:
self.logger.info("id token expired, and refresh fail, starting new OAuth flow.")
# 使用 threading 來避免 GUI 凍結
login_thread = threading.Thread(target=self.initiate_oauth_flow)
login_thread.start()
def check_existing_id_token(self):
try:
file_path = os.path.join(get_project_root(), LOCAL_ID_TOKEN_FILE)
if os.path.exists(file_path):
with open(file_path, "r") as f:
google_id_token = f.read()
idinfo = id_token.verify_oauth2_token(google_id_token, requests.Request(), CLIENT_ID)
self.logger.info(f"User {idinfo['email']} is already logged in.")
return idinfo
except Exception as e:
self.logger.warning(f"ID token expired: {e}")
return None
def check_existing_credential(self):
try:
file_path = os.path.join(get_project_root(), LOCAL_CREDENTIAL_FILE)
if os.path.exists(file_path):
creds = Credentials.from_authorized_user_file(file_path, SCOPES)
creds.refresh(requests.Request())
idinfo = id_token.verify_oauth2_token(creds.id_token, requests.Request(), CLIENT_ID)
self.logger.info(f"User {idinfo['email']} is just refreshed.")
return idinfo
except Exception as e:
self.logger.warning(f"No valid existing token found: {e}")
return None
def initiate_oauth_flow(self):
flow = InstalledAppFlow.from_client_secrets_file(resource_path(CLIENT_SECRET_FILE), SCOPES)
creds = flow.run_local_server(port=0)
with open(os.path.join(get_project_root(), LOCAL_CREDENTIAL_FILE), "w") as f:
f.write(creds.to_json())
google_id_token = creds.id_token
with open(os.path.join(get_project_root(), LOCAL_ID_TOKEN_FILE), "w") as f:
f.write(google_id_token)
try:
idinfo = id_token.verify_oauth2_token(google_id_token, requests.Request(), CLIENT_ID)
self.logger.info(f'logged in {idinfo}')
except ValueError as e:
# Token 無效或驗證失敗
self.logger.error(f"Token verification failed: {e}")
Newman 2025/9/16
導覽頁:紐曼的技術筆記-索引
















