COSCUP 2026

同個 Email、兩種登入方式 Google 登入整合的
四個帳號接管路徑

週六下午,你明明有很多選擇 但你選擇來參加 COSCUP

先為願意在假日走進來學習的自己與大家,掌聲鼓勵!

02 / 40
BEFORE WE START

開場提醒

共筆文件
跟上筆記、掌握進度
會後簡報會放在這裡
有問題也可以直接打在這裡
也歡迎大家一起共筆
03 / 40
Gui 桂

Gui

軟體工程師

我是 Gui 桂,目前是一名網頁開發者。從高職就讀資處科開始到後來進入職場,深知這個領域需要不斷追求卓越、終身成長,持續往技能更加全面的開發者邁進。

同時不斷參與社群,參與研討會志工與讀書會,也即將擔任網頁課程老師,希望能將過去所學透過社群分享給更多人。

04 / 40
ABOUT ME, TOO

我平常的開發模式

BDDBehavior-Driven Development?

才不是

BDDBeer-Driven Development

不管是寫 code,還是準備這場 COSCUP 分享,卡關的時候先喝一杯,思路常常就順了。

05 / 40
SECTION
1

我們的會員系統,一開始
都是怎麼接上 Google 登入的

06 / 40
SECTION 1

先看使用者眼中看到的畫面

1
點下登入 使用者點「使用 Google 登入」
2
同意授權 Google 跳出畫面,使用者選帳號、確認授權
3
完成登入 自動跳回平台,已經是登入狀態
07 / 40
SECTION 1

Google 第三方登入是怎麼跑一輪的

使用者
我們的網站
Google
1點下「使用 Google 登入」,跳去 Google 同意授權
2Google 核發一份 JWT 格式的身分憑證,附上 email、sub(主體識別碼)
3後端讀出憑證,比對或建立會員、完成登入
08 / 40
SECTION 1

名詞解釋:Provider 與 Sub

provider身分提供者

替你做身分驗證的第三方服務,例如 Google、Facebook、Apple、Microsoft、LINE。走這條登入路徑時,帳密驗證的工作都在 provider 那邊做完,預設不用自己存密碼、比對密碼,只要驗證它簽發的 JWT 是不是真的。

sub主體識別碼

provider 給你的其實是一組 UID,Google 認人靠這個,不是 email。sub 像身分證字號不會變,email 像聯絡資料,隨時會換。這次分享的系統,一開始用的就是 email。

09 / 40
SECTION 1

還有一個名詞:平台帳號

平台帳號跟 provider 相對的另一條路

使用者直接在我們自己的系統設帳號、密碼登入,不透過任何 provider。今天講的「帳密驗證」「密碼」,指的都是這一種帳號;跟走 Google 登入是兩條平行的路,同一個人可能兩條路都走過。

10 / 40
SECTION 1

這一步用 Email 找會員,問題就從這裡開始

從 provider 拿到使用者的 emailsubject identifiersub

用 email 去 DB 找會員,找到就直接登入、找不到就建立新會員

11 / 40
SECTION 1

你的網站,符合以下任何一個條件嗎?

1
同時支援「平台帳號註冊」跟「第三方登入」
2
允許會員自己刪除帳號
3
Email 可能已被別人在 Google 端搶先註冊(例如離職員工的舊信箱)
12 / 40
SECTION
2

接下來我們可以看一下
真實發生過的案例

13 / 40
SECTION 2

買下一個舊網域,就能登入別人的系統?

一間公司倒閉,網域被別人買走,買家只要照舊員工的信箱格式建新帳號,就能用 Google 登入這間公司以前用過的 Slack、Zoom、ChatGPT,甚至 HR 系統。問題不是 Google 沒有不變的識別碼,sub 就是那個不變的識別碼;問題是這些被入侵的服務一開始沒拿 sub 當識別,用的是網域轉手就會重複出現的 email。

來源:Truffle Security 原文 · SecurityWeek
14 / 40
SECTION 2

近兩年,剛好對上主題的兩個 CVE(公開漏洞編號)

CVE-2025-24856(搶先卡位)
TYPO3 的第三方登入套件

攻擊者可以搶先用你的 email 註冊、卡在你前面;等你之後真的用這個 email 走第三方登入,進到的就是他預埋的那個帳號。

CVE-2024-56329(未確認綁定)
Socialstream

第三方帳號被綁到既有帳號時,系統沒有跳出任何確認畫面就直接綁上去;帳號的主人從頭到尾沒有機會說不。

15 / 40
SECTION 2

某訂房網也曾被這樣入侵過

某訂房網的 Google/Facebook 登入流程裡,有好幾個環節沒有再次確認授權是不是使用者本人要的。攻擊者串起這幾個環節,把授權過程中的憑證半路攔截走,直接拿到受害者的帳號、訂房紀錄,甚至能取消或代訂行程。這類是 OAuth 流程實作層的漏洞,跟今天要講的 email 比對是不同層,但同樣說明:整合細節一出錯,代價都是帳號。

來源:Salt Labs 研究報告
16 / 40
SECTION 2

網域轉手、自動接管、殘留綁定、搶先卡位,四個接管路徑。網域轉手的案例剛剛看過了,怎麼防、留到最後收尾。

17 / 40
SECTION 2

接下來要處理的三件事

3
已經有平台帳號,想再多綁一個 Google 登入
4
不想綁了:解除 Google 綁定、刪除帳號
5
帳號還沒建立,就先被別人卡位
18 / 40
SECTION
3

已經有平台帳號,
想再多綁一個 Google 登入

19 / 40
SECTION 3

Jack 只需要同一個 Email,就能拿到平台的 JWT

前提:Jack 登入得了這個信箱的 Google 帳號,例如離職員工的公司信箱被回收、再發給新人

Amy
Jack
後端
1用 amy@example.com 註冊、設密碼
2同一個 email 走 Google 登入
3用 email 找到 Amy,簽出平台自己的 JWT
4拿 JWT 登入 Amy 帳號
20 / 40
SECTION 3

只要平台帳號已經設過密碼,我們就拒絕 Google 自動登入接管。使用者必須先用密碼登入,登入之後,才能在會員頁面主動發起綁定。

原因是 provider 驗證的是「你能登入這個 Google 帳號」,不是「你是這個平台帳號的擁有者」。前面 Socialstream 的 CVE 缺的就是這一步:綁定沒經過本人確認。讓綁定只能由密碼登入後的使用者主動發起,這個動作本身就是確認。

21 / 40
SECTION 3

偵測到帳號已有密碼,就擋下 Google 登入

member.controller.ts
if (existing_member) {
  // 只要帳號設過密碼,不管當初怎麼註冊的,一律擋下 Google 自動登入
  if (existing_member.password) {
    response.status(409).json({
      status: "account_exists",
      message: "請先用密碼登入後,至會員頁面綁定 Google 帳號。",
    });
    return;
  }
}
409 表示帳號衝突,跟登入驗證失敗的 401 是不同狀況;前端可以藉此準確導向綁定流程,不會誤判成登入錯誤。
22 / 40
SECTION 3

已有平台帳號,綁定 Google 的正確流程

1
密碼登入 先用密碼登入平台帳號,證明你是帳號的主人
2
主動發起 在會員頁面點「綁定 Google」,跳去 Google 完成授權
3
寫入綁定 後端驗過 Google 簽回來的憑證,把 google 加進 login_methods
4
兩條路都通 之後用密碼、用 Google,登入的都是同一個帳號
23 / 40
SECTION 3

實作這段邏輯時,有幾個地方要注意

1
用不同的錯誤代碼區分「帳號已存在」跟「登入失敗」,畫面才能準確帶使用者到下一步,不會顯示錯的提示。
2
只看 login_methods 不夠準,Google 使用者事後也可能補設密碼(怎麼補,下一章會講),這時清單裡還是有 Google,真正該檢查的是有沒有密碼。
24 / 40
SECTION
4

解綁、刪帳號之前,
使用者得先有密碼

25 / 40
SECTION 4

Google 登入的使用者,平台帳號原本就沒有密碼

沒有密碼,就沒辦法解綁、也沒辦法安全刪除帳號。所以第一步,要先讓 Google 使用者能自己設定密碼。

26 / 40
SECTION 4
change_password — member.controller.ts
// Google 使用者(無密碼):跳過舊密碼驗證,視為「設定密碼」
const is_google_user_without_password =
  member.login_methods.includes('google') && !member.password;

if (!is_google_user_without_password) {
  // 平台使用者:仍然需要驗舊密碼
  if (!old_password) {
    return response.status(400).json({
      status: "old_password_required",
    });
  }
}

// ……以下接著比對舊密碼是否正確、寫入新密碼(略)
判斷式只看兩件事:是 Google 登入、而且密碼欄位是空的。符合才允許跳過舊密碼驗證。前提是後端本來就有把 Google JWT 驗證做對,確認它沒被偽造、也確實是核發給你的應用程式。
27 / 40
SECTION 4

刪除帳號沒處理綁定,會留下什麼

1
Amy 用 Google 註冊平台帳號,後來按下「刪除帳號」
2
平台執行軟刪除,但沒處理 Google 綁定關聯
3
三個月後,這個 email 換手到 Jack 手上(例如公司信箱回收),他拿它走 Google 登入
4
軟刪除只是標記停用,資料還在;只要有一段查詢漏了檢查這個標記,Jack 就能直接拿到 Amy 的歷史資料
28 / 40
SECTION 4

拆成兩步:先解綁,才能刪帳號

1
解綁,但解綁前要先有平台密碼,不然會把自己鎖在系統外
2
刪帳號,這時 login_methods 裡已經沒有 Google,可以安全執行軟刪除
29 / 40
SECTION 4
delete_account — member.controller.ts
// 有第三方登入綁定,要先解綁才能刪除
if (member.login_methods.includes('google')) {
  response.status(400).json({
    status: "google_bound",
    message: "請先解除 Google 綁定後再刪除帳號。",
  });
  return;
}

member.is_disabled = true;
await member.save();
有第三方登入綁定,要先解綁才能刪除,避免刪除後留下沒人管的 Google 綁定關聯
30 / 40
SECTION 4
unbind_google_account — 同檔案
// 沒有密碼不能解綁,避免把使用者鎖在系統外
if (!current_member.password
    || current_member.password.trim() === '') {
  response.status(400).json({
    status: "error",
    message: "請先設定密碼,或聯絡客服協助處理。",
  });
  return;
}

await Member.findByIdAndUpdate(current_member._id, {
  $pull: { login_methods: 'google' },
});
沒有密碼不能解綁,這跟前面設定密碼那一步是配對的。
31 / 40
SECTION 4

設定密碼、解綁、刪帳號,是同一套流程

少了設定密碼,Google 使用者解綁後就沒有密碼可以登入,會把自己鎖在系統外。這幾個改動,連同前面擋下 Google 自動接管平台帳號的修補,是同一波修補、一起上線。

32 / 40
SECTION
5

Pre-account Hijacking:
當帳號還沒建立就先被別人卡位

33 / 40
SECTION 5

帳號還沒建立,就已經能被卡位

1
Jack 知道(或猜到) Amy 會用 amy@company.com 註冊某個服務
2
Jack 搶先註冊平台帳號,但他根本沒有那個信箱的存取權
3
Amy 後來真的來註冊,發現「Email 已存在」,於是改用 Google 登入
4
系統把 Google 登入自動關聯到那個未驗證的 Jack 帳號, Amy 進到的其實是 Jack 預埋的帳號
34 / 40
SECTION 5

未驗證的帳號,不該有任何權限

不能留設定、不能收通知,更不能被拿去接管,這也代表 email 驗證要在註冊當下就同步阻擋,不能先讓帳號能用、之後才補驗證。Jack 那個從沒驗證過 email 的預埋帳號,卻已經能被 Google 登入自動關聯,就是這件事沒做好的後果。

35 / 40
SECTION 5

接管未驗證帳號,要重新驗證 Email 所有權

Amy 發現「Email 已存在」時,不能直接用 Google 登入接手那個帳號;系統得先寄一封驗證信,等她點過信裡的連結、確認她真的擁有這個信箱,才能把帳號交給她。

36 / 40
SECTION 5

Email 換手後,舊帳號 session 全部失效

失效處理(示意)
// 確認 email 已換手(重新驗證所有權通過)之後
await Session.deleteMany({ member_id: old_member._id });
await RefreshToken.deleteMany({ member_id: old_member._id });
就算前面都做對,只要舊的登入狀態還留著,帳號的前一個主人(例如卡位的 Jack)就還進得來。作廢的是伺服器端 session 跟長效的 refresh token;短效的 JWT 簽出去就收不回來,讓它自然過期,但拿不到新的 token,就進不來了。
37 / 40
SECTION 5

如果你的會員系統還沒動工

第一天就用 sub 當識別,email 只當聯絡資料。

別誤會,前面不是白講。第一天用 sub,關掉的是網域轉手那條路的根源;但綁定流程、刪帳號要先解綁、未驗證帳號不給權限、email 所有權驗證,其他每一道防線還是得做。資安不是找一顆銀彈,是每一層都補好,sub 只是讓地基一開始就是對的。

38 / 40
RECAP

四個路徑,四套防禦

1
網域轉手:email 會跟著網域換手,換手後舊 session 全部失效,比對鍵長期朝 sub 靠攏
2
自動接管:帳號已有密碼就擋下 Google 自動登入,先用密碼登入、再主動發起綁定
3
殘留綁定:先解綁才能刪帳號,解綁前要先讓使用者設定密碼
4
搶先卡位:未驗證帳號不給權限,接管前重新驗證 email 所有權
39 / 40
COSCUP 2026

謝謝聆聽 Q&A

共筆文件
此 Gui 非彼 GUI
Gui Blog - 網站的工具人
BACKUP · QA

409 跟 401 分開回,會不會被拿來列舉帳號?

先登得進那個 Google 帳號,才看得到這個 409。

這段程式跑在 Google 登入成功之後,手上只有一堆 email 清單的人,卡在 Google 那關就進不來了。錯誤訊息要不要講得這麼白,我們當時也討論過,最後想通了:看得到這個訊息的人,本來就是信箱的主人,講清楚比較實在。

BACKUP · QA

token 裡不是有 email_verified 嗎?看它不就好了?

這個欄位我們有看,但它管不到這件事

它只能告訴你這個信箱在 Google 那邊驗證過。網域轉手之後,新主人申請的信箱一樣是驗證過的,拿到的還是 true。加上各家 provider 對這個欄位的實作標準不一,有些拿到的值永遠是 true,所以它沒辦法拿來判斷帳號是誰的。

BACKUP · QA

換成 Facebook、LINE、Apple 登入,這些問題還在嗎?

換哪一家,這些問題都還在

因為問題出在我們自己拿 email 找人,跟接哪一家 provider 沒有關係。要留意的是各家給的 email 性質不太一樣,像 Apple 會給使用者一個隨機的轉寄信箱。另外 sub 只在同一家 provider 底下唯一,同時接好幾家的話,記得連同是哪一家一起存。

BACKUP · QA

平台再加一層兩步驟驗證,能擋掉多少?

兩步驟驗證很值得做,但它補不了這個洞

開了之後,就算 Google 登入被接管,少了第二關還是進不來。不過實際上願意開的使用者很少,我們也只在改密碼這類操作上強制。帳號認錯人終究是比對邏輯的問題,兩步驟驗證能做的,是讓認錯人的後果輕一點。