先為願意在假日走進來學習的自己與大家,掌聲鼓勵!
我是 Gui 桂,目前是一名網頁開發者。從高職就讀資處科開始到後來進入職場,深知這個領域需要不斷追求卓越、終身成長,持續往技能更加全面的開發者邁進。
同時不斷參與社群,參與研討會志工與讀書會,也即將擔任網頁課程老師,希望能將過去所學透過社群分享給更多人。
不管是寫 code,還是準備這場 COSCUP 分享,卡關的時候先喝一杯,思路常常就順了。
替你做身分驗證的第三方服務,例如 Google、Facebook、Apple、Microsoft、LINE。走這條登入路徑時,帳密驗證的工作都在 provider 那邊做完,預設不用自己存密碼、比對密碼,只要驗證它簽發的 JWT 是不是真的。
provider 給你的其實是一組 UID,Google 認人靠這個,不是 email。sub 像身分證字號不會變,email 像聯絡資料,隨時會換。這次分享的系統,一開始用的就是 email。
使用者直接在我們自己的系統設帳號、密碼登入,不透過任何 provider。今天講的「帳密驗證」「密碼」,指的都是這一種帳號;跟走 Google 登入是兩條平行的路,同一個人可能兩條路都走過。
從 provider 拿到使用者的 email 跟 subject identifier(sub)
用 email 去 DB 找會員,找到就直接登入、找不到就建立新會員
一間公司倒閉,網域被別人買走,買家只要照舊員工的信箱格式建新帳號,就能用 Google 登入這間公司以前用過的 Slack、Zoom、ChatGPT,甚至 HR 系統。問題不是 Google 沒有不變的識別碼,sub 就是那個不變的識別碼;問題是這些被入侵的服務一開始沒拿 sub 當識別,用的是網域轉手就會重複出現的 email。
攻擊者搶先用你的 email 註冊,就能卡在你前面。
綁定 Google 帳號時,沒跳出確認畫面就直接綁上去了。
某訂房網的 Google/Facebook 登入流程裡,有好幾個環節沒有再次確認授權是不是使用者本人要的。攻擊者串起這幾個環節,把授權過程中的憑證半路攔截走,直接拿到受害者的帳號、訂房紀錄,甚至能取消或代訂行程。這類是 OAuth 流程實作層的漏洞,跟今天要講的 email 比對是不同層,但同樣說明:整合細節一出錯,代價都是帳號。
網域轉手、自動接管、殘留綁定、搶先卡位,四個路徑說到底都是同一個問題:拿 email 當身分,卻沒想清楚所有狀況。
網域轉手的真實案例剛剛看過了,怎麼防、留到最後收尾;剩下三個,接下來照著我們自己系統的修補,一段一段走。
前提:Jack 登入得了這個信箱的 Google 帳號,例如離職員工的公司信箱被回收、再發給新人
只要平台帳號已經設過密碼,我們就拒絕 Google 自動登入接管。使用者必須先用密碼登入,登入之後,才能在會員頁面主動發起綁定。
原因是 provider 驗證的是「你能登入這個 Google 帳號」,不是「你是這個平台帳號的擁有者」。前面 Socialstream 的 CVE 缺的就是這一步:綁定沒經過本人確認。讓綁定只能由密碼登入後的使用者主動發起,這個動作本身就是確認。
if (existing_member) {
// 只要帳號設過密碼,不管當初怎麼註冊的,一律擋下 Google 自動登入
if (existing_member.password) {
response.status(409).json({
status: "account_exists",
message: "請先用密碼登入後,至會員頁面綁定 Google 帳號。",
});
return;
}
}
login_methods 不夠準,Google 使用者事後也可能補設密碼(怎麼補,下一章會講),這時清單裡還是有 Google,真正該檢查的是有沒有密碼。Google 登入的使用者,平台帳號原本就沒有密碼。
沒有密碼,就沒辦法解綁、也沒辦法安全刪除帳號。所以第一步,要先讓 Google 使用者能自己設定密碼。
// 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",
});
}
}
// ……以下接著比對舊密碼是否正確、寫入新密碼(略)
login_methods 裡已經沒有 Google,可以安全執行軟刪除// 有第三方登入綁定,要先解綁才能刪除
if (member.login_methods.includes('google')) {
response.status(400).json({
status: "google_bound",
message: "請先解除 Google 綁定後再刪除帳號。",
});
return;
}
member.is_disabled = true;
await member.save();
// 沒有密碼不能解綁,避免把使用者鎖在系統外
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' },
});
設定密碼、解綁、刪帳號,是同一套流程。
少了設定密碼,Google 使用者解綁後就沒有密碼可以登入,會把自己鎖在系統外。這幾個改動,連同前面擋下 Google 自動接管平台帳號的修補,是同一波修補、一起上線。
amy@company.com 註冊某個服務不能留設定、不能收通知,更不能被拿去接管,這也代表 email 驗證要在註冊當下就同步阻擋,不能先讓帳號能用、之後才補驗證。Jack 那個從沒驗證過 email 的預埋帳號,卻已經能被 Google 登入自動關聯,就是這件事沒做好的後果。
Amy 發現「Email 已存在」時,不能直接用 Google 登入接手那個帳號;系統得先寄一封驗證信,等她點過信裡的連結、確認她真的擁有這個信箱,才能把帳號交給她。
// 確認 email 已換手(重新驗證所有權通過)之後
await Session.deleteMany({ member_id: old_member._id });
await RefreshToken.deleteMany({ member_id: old_member._id });
還記得網域轉手的案例嗎?email 會跟著網域一起換手,sub 不會。那能不能把比對鍵換成 sub?
第一天就用 sub 當識別,email 只當聯絡資料。
剛剛那三個包袱,全部是系統已經上線才有的遷移成本。新系統從第一天就拿 provider 加 sub 當比對鍵,再把 email 所有權驗證做好,今天講的四個路徑,大多從源頭就不成立。