先為願意在假日走進來學習的自己與大家,掌聲鼓勵!
我是 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 註冊、卡在你前面;等你之後真的用這個 email 走第三方登入,進到的就是他預埋的那個帳號。
第三方帳號被綁到既有帳號時,系統沒有跳出任何確認畫面就直接綁上去;帳號的主人從頭到尾沒有機會說不。
某訂房網的 Google/Facebook 登入流程裡,有好幾個環節沒有再次確認授權是不是使用者本人要的。攻擊者串起這幾個環節,把授權過程中的憑證半路攔截走,直接拿到受害者的帳號、訂房紀錄,甚至能取消或代訂行程。這類是 OAuth 流程實作層的漏洞,跟今天要講的 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_methodslogin_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 });
第一天就用 sub 當識別,email 只當聯絡資料。
別誤會,前面不是白講。第一天用 sub,關掉的是網域轉手那條路的根源;但綁定流程、刪帳號要先解綁、未驗證帳號不給權限、email 所有權驗證,其他每一道防線還是得做。資安不是找一顆銀彈,是每一層都補好,sub 只是讓地基一開始就是對的。
要先登得進那個 Google 帳號,才看得到這個 409。
這段程式跑在 Google 登入成功之後,手上只有一堆 email 清單的人,卡在 Google 那關就進不來了。錯誤訊息要不要講得這麼白,我們當時也討論過,最後想通了:看得到這個訊息的人,本來就是信箱的主人,講清楚比較實在。
這個欄位我們有看,但它管不到這件事。
它只能告訴你這個信箱在 Google 那邊驗證過。網域轉手之後,新主人申請的信箱一樣是驗證過的,拿到的還是 true。加上各家 provider 對這個欄位的實作標準不一,有些拿到的值永遠是 true,所以它沒辦法拿來判斷帳號是誰的。
換哪一家,這些問題都還在。
因為問題出在我們自己拿 email 找人,跟接哪一家 provider 沒有關係。要留意的是各家給的 email 性質不太一樣,像 Apple 會給使用者一個隨機的轉寄信箱。另外 sub 只在同一家 provider 底下唯一,同時接好幾家的話,記得連同是哪一家一起存。
兩步驟驗證很值得做,但它補不了這個洞。
開了之後,就算 Google 登入被接管,少了第二關還是進不來。不過實際上願意開的使用者很少,我們也只在改密碼這類操作上強制。帳號認錯人終究是比對邏輯的問題,兩步驟驗證能做的,是讓認錯人的後果輕一點。