第三方登入後的帳號綁定
第三方登入最容易被低估的不是串接 Provider,而是登入成功之後:
這個 Google / LINE / GitHub 身份,應該對應到哪一個本地帳號?看起來只是資料表關聯,但實際上它牽涉到帳號接管風險。
最常見的危險想法是:
第三方登入回傳的 email 跟本地帳號 email 一樣,那就直接合併吧。這句話在很多情境下會出事。
因為 email 只是屬性,不一定是安全的綁定證據。第三方登入真正穩定的身份識別通常是:
provider + issuer + subject或:
provider + provider_user_id帳號綁定的核心原則是:
你要確認「正在操作的人」真的控制要被綁定的本地帳號,
也真的控制要加入的外部身份。兩邊都要確認,才叫安全綁定。
一、 一句話回答
如果面試官問:
第三方登入後,帳號綁定怎麼設計?
可以先回答:
第三方登入後不能只用 email 自動合併帳號,應該用 provider + issuer + subject 或 provider user id 建立 ExternalIdentity,再對應本地 user。若使用者已登入本地帳號並主動新增 Google、LINE、GitHub 登入方式,可以在重新驗證後綁定;若使用者未登入,但第三方回傳 email 與既有帳號相同,應要求額外驗證,例如本地密碼、email OTP、MFA 或登入既有帳號後再綁定。帳號綁定是高風險操作,需要防止 account takeover,並要處理 email 未驗證、provider 不回 email、解除綁定、最後一個登入方式、通知信、audit log 與 session 風險。
這題的重點不是「多一張表」而已,而是:
- 外部身份怎麼唯一識別。
- 什麼時候可以自動建立帳號。
- 什麼時候不能自動合併帳號。
- 已登入綁定和未登入合併的風險差異。
- 解除綁定如何避免鎖死使用者。
- 綁定成功後如何通知與稽核。
二、 先定義:登入、註冊、綁定、合併
這幾個詞常被混用,但系統設計時要分清楚。
| 行為 | 意義 | 風險 |
|---|---|---|
| 第三方登入 | 使用外部 Provider 驗證身份後登入本地系統 | Provider token 驗證錯誤 |
| 第三方註冊 | 外部身份第一次進入系統,建立新 user | email 是否可信、資料不足 |
| 帳號綁定 | 已有 user 新增一個外部登入方式 | 錯綁造成帳號接管 |
| 帳號合併 | 兩個既有 user 被合成一個 | 資料歸屬與權限混淆 |
| 解除綁定 | 從 user 移除某個外部身份 | 使用者可能失去登入方式 |
面試中要特別小心「綁定」和「合併」。
帳號綁定通常是:
同一個 user 增加一種登入方式。帳號合併則是:
兩個 user 的資料要合在一起。合併的風險比綁定更高,因為它牽涉到訂單、付款、權限、組織、稽核資料與資料所有權。
三、 基本資料模型
建議不要把所有第三方資訊塞進 users table。
比較好的模型是:
users
login_credentials
external_identities例如:
create table users (
id uuid primary key,
email text,
email_verified boolean not null default false,
name text,
avatar_url text,
created_at timestamp not null,
updated_at timestamp not null
);
create table password_credentials (
id uuid primary key,
user_id uuid not null references users(id),
password_hash text not null,
created_at timestamp not null,
updated_at timestamp not null,
unique (user_id)
);
create table external_identities (
id uuid primary key,
user_id uuid not null references users(id),
provider text not null,
issuer text,
subject text not null,
email text,
email_verified boolean not null default false,
display_name text,
avatar_url text,
profile_json jsonb,
linked_at timestamp not null,
last_login_at timestamp,
unique (provider, issuer, subject)
);重點是:
external_identities 的唯一鍵應該綁 provider 身份,
不是只綁 email。例如:
google + https://accounts.google.com + 110248495921238986420
line + https://access.line.me + U4af4980629
github + null + 12345678這樣你才能支援:
- 一個 user 綁多個 Provider。
- 同一 Provider 身份不能綁到多個 user。
- Provider 不提供 email 也能登入。
- email 變更時不會失去 identity mapping。
- 後續接 Apple、Microsoft、企業 OIDC、SAML。
四、 第一次第三方登入
第一次第三方登入時,系統通常會遇到三種情境。
情境一:external identity 已存在
Google sub 已經綁到 user A。這是最單純的情況:
驗證 Provider token -> 找到 external identity -> 找到 user -> 建立 session。流程:
const identity = await verifyProviderIdentity(tokens)
const externalIdentity = await db.externalIdentity.findUnique({
where: {
provider_issuer_subject: {
provider: identity.provider,
issuer: identity.issuer,
subject: identity.subject,
},
},
include: { user: true },
})
if (externalIdentity) {
await updateLastLoginAt(externalIdentity.id)
return login(externalIdentity.user)
}這裡不需要再用 email 找 user,因為外部身份已經綁定過。
情境二:external identity 不存在,email 也沒有既有帳號
Google sub 不存在。
email = alice@example.com。
users 裡沒有 alice@example.com。可以建立新 user,再建立 external identity。
const user = await db.user.create({
data: {
email: identity.email,
emailVerified: identity.emailVerified,
name: identity.name,
avatarUrl: identity.avatarUrl,
},
})
await db.externalIdentity.create({
data: {
userId: user.id,
provider: identity.provider,
issuer: identity.issuer,
subject: identity.subject,
email: identity.email,
emailVerified: identity.emailVerified,
profileJson: identity.rawProfile,
linkedAt: new Date(),
},
})如果 Provider 沒有 email,而你的產品需要 email,可以建立「待補資料」狀態:
external identity 已驗證,但 user profile 尚未完成。使用者登入後必須補填並驗證 email,才開放完整功能。
情境三:external identity 不存在,但 email 已有既有帳號
這是最危險的情境。
Google sub 不存在。
email = alice@example.com。
users 裡已經有 alice@example.com。不能直接自動綁定。
你至少要先問:
- Google 回傳的 email 是否 verified?
- 本地帳號的 email 是否 verified?
- 使用者是否已登入本地帳號?
- 這是不是使用者主動在帳號設定頁新增登入方式?
- 這個操作是否需要 re-authentication?
- 帳號是否有 MFA?
- 是否有異常風險,例如新裝置、新地點、大量嘗試?
安全做法是進入「待確認綁定」流程,而不是直接合併。
五、 已登入綁定:最安全的主路徑
最推薦的綁定路徑是:
使用者先登入本地帳號 -> 到帳號設定 -> 點擊綁定 Google -> 完成 Provider 登入 -> 綁定到目前 user。流程如下:
這個流程安全性比較高,因為你已經知道:
目前 session 控制 user A。
Provider callback 證明使用者也控制 Google identity。但即使如此,綁定仍是高風險操作,建議要求重新驗證。
例如:
- 最近 5 到 15 分鐘內輸入過密碼。
- 通過 MFA。
- 通過 passkey / WebAuthn。
- 對敏感帳號要求 email OTP 或管理員確認。
OWASP 的身份驗證建議裡,也把高風險操作後的重新驗證與 session/token 輪替視為重要防線。帳號綁定就屬於這種高風險操作。
六、 未登入合併:要非常保守
另一種常見路徑是:
使用者在登入頁點 Google。
Google 回傳 email。
系統發現本地已存在相同 email。這時候不能直接做:
email 相同 -> 綁定到既有 user -> 登入成功。比較安全的流程是:
提示:這個 email 已有帳號,請登入既有帳號後完成綁定。例如:
- 使用者用 Google 登入。
- 系統發現
alice@example.com已存在。 - 系統建立一個短效 pending link intent。
- 要求使用者輸入本地密碼、通過 email OTP 或 MFA。
- 驗證成功後,把 Google identity 綁到該 user。
- 建立 session。
- 寄出通知信。
概念:
if (existingUser && !linkedIdentity) {
const intent = await createPendingLinkIntent({
userId: existingUser.id,
providerIdentity: identity,
expiresInMinutes: 10,
})
return requireLocalAccountVerification(intent.id)
}這個流程的本質是:
第三方 Provider 證明你控制外部身份;
本地驗證證明你也控制既有本地帳號。兩邊都確認後才綁定。
七、 Email verified 也不能無腦合併
很多人會問:
如果 Google email_verified = true,是否就可以自動合併?
仍然不建議無腦合併。
email_verified = true 代表 Provider 認為這個使用者控制該 email。但你的系統還要考慮:
- 本地帳號是否也真的由同一人控制?
- 本地 email 是否 verified?
- 本地帳號是否可能是舊帳號或企業帳號?
- Provider 的 email 是否可變更?
- 這個 email 是否屬於共用信箱或角色信箱?
- 是否存在 pre-account takeover 風險?
- 產品是否涉及金融、醫療、政府、企業管理權限?
比較合理的分層策略:
| 產品風險 | 策略 |
|---|---|
| 低風險內容產品 | verified email 可用來提示快速綁定,但仍應保留確認步驟 |
| 一般會員系統 | 要求登入既有帳號或 email OTP 後綁定 |
| 金融 / 醫療 / 政府 | 要求強重新驗證、MFA,甚至人工或高強度身份確認 |
| 企業 SaaS | 還要檢查 tenant、domain、IdP policy、管理員設定 |
不要把「email verified」等同於「可以取得本地帳號所有權」。
八、 Pre-account takeover 風險
pre-account takeover 是社群登入和 email 註冊混用時常見的風險。
舉例:
- 攻擊者先用
victim@example.com註冊本地帳號。 - 系統沒有驗證 email,或允許未驗證帳號保留。
- 受害者之後用 Google 登入,Google email 是
victim@example.com。 - 系統看到 email 一樣,就把 Google identity 綁到攻擊者先建立的 user。
- 攻擊者仍能用原本密碼登入該 user。
結果是:
受害者以為自己用 Google 建立了帳號,
但帳號其實被攻擊者預先佔住。防線:
- 本地 email 未驗證前,不允許被視為可合併身份。
- 第三方登入遇到既有未驗證 email 帳號時,不自動綁定。
- 可要求既有帳號先完成 email 驗證或密碼確認。
- 對新註冊但未驗證 email 的帳號設定短效保留期。
- 重要系統不要允許未驗證 email 帳號持有敏感資料。
這類問題之所以麻煩,是因為它常常不是 OAuth 規格錯,而是產品帳號模型的邏輯錯。
九、 External Identity 已被別人綁定
另一個必須處理的情境:
user A 想綁定 Google identity X,
但 X 已經綁到 user B。這時不能直接搬移。
應該回應:
這個 Google 帳號已經連結到另一個帳號。但不要透露太多:
這個 Google 帳號已經連結到 bob@example.com。這可能造成帳號枚舉或隱私洩漏。
若產品要支援轉移,必須走更嚴格流程:
- 使用者要能登入 user B。
- 或由客服 / 管理員經過身份確認後處理。
- 轉移要通知 user A 和 user B 的主要 email。
- 記錄 audit log。
- 可能需要撤銷相關 session。
一般產品最簡單安全的策略是:
同一外部身份只能綁定一個 user,不支援自助轉移。十、 解除綁定
解除綁定也不是直接刪資料就好。
要先確認:
解除後,使用者是否仍有至少一種可用登入方式?例如 user 目前只有 Google 登入,沒有密碼、沒有其他 Provider、沒有 passkey。
如果允許解除 Google,就會變成:
使用者把自己鎖在門外。解除綁定前應檢查:
- 是否還有密碼登入。
- 是否還有其他第三方 Provider。
- 是否有 passkey。
- 是否有企業 SSO。
- 是否有可用的帳號恢復流程。
若這是最後一個登入方式,應要求先設定另一個方式:
請先設定密碼或綁定另一個登入方式,再解除 Google。解除綁定也是高風險操作,建議:
- 要求 re-authentication。
- 寄出通知。
- 記錄 audit log。
- 對企業帳號可要求管理員權限。
十一、 通知與稽核
帳號綁定成功、失敗、解除,都應該留下紀錄。
常見事件:
external_identity_link_started
external_identity_link_succeeded
external_identity_link_failed
external_identity_already_linked
external_identity_unlinked
external_identity_link_requires_reauth
external_identity_link_conflictaudit log 可以包含:
- user id。
- provider。
- issuer。
- subject hash。
- ip。
- user agent。
- device id。
- risk score。
- created at。
- result。
注意:log 裡不要直接塞完整 token、完整 ID Token 或敏感 profile。
通知信可以包含:
你的帳號剛剛新增了 Google 登入方式。
如果這不是你本人操作,請立即重設密碼並撤銷登入裝置。通知的目的不是增加儀式感,而是讓使用者能發現異常綁定。
十二、 Session 與風險控制
綁定成功後,要不要撤銷舊 session?
看風險。
低風險情境:
使用者已登入,最近剛重新驗證,新增 Google 登入。可以保留 session,但記錄事件。
高風險情境:
新裝置、新地點、剛完成帳號恢復、剛新增 MFA、剛綁定企業 SSO。可以考慮:
- 旋轉 session id。
- 重新簽發 refresh token。
- 撤銷其他裝置 session。
- 要求 MFA。
- 暫時限制敏感操作。
- 發送安全通知。
OWASP Session Management Cheat Sheet 也建議在高風險事件後注意 session integrity、重新驗證與 token/session 輪替。
十三、 API 設計範例
已登入綁定
POST /api/account/external-identities/google/link
GET /api/account/external-identities/google/callback流程:
app.post('/api/account/external-identities/google/link', requireAuth, async (req, res) => {
await requireRecentReauth(req.user.id)
const flow = await createOAuthFlow({
userId: req.user.id,
purpose: 'link',
provider: 'google',
})
res.json({ url: flow.authorizationUrl })
})callback:
app.get('/api/account/external-identities/google/callback', async (req, res) => {
const flow = await consumeOAuthFlow(req.query.state)
if (!flow || flow.purpose !== 'link') {
throw new Error('Invalid link flow')
}
const identity = await verifyGoogleCallback(req, flow)
const existing = await findExternalIdentity(identity)
if (existing && existing.userId !== flow.userId) {
throw new Error('External identity already linked')
}
await db.externalIdentity.create({
data: {
userId: flow.userId,
provider: identity.provider,
issuer: identity.issuer,
subject: identity.subject,
email: identity.email,
emailVerified: identity.emailVerified,
linkedAt: new Date(),
},
})
await auditLog('external_identity_link_succeeded', {
userId: flow.userId,
provider: identity.provider,
})
res.redirect('/settings/security')
})解除綁定
DELETE /api/account/external-identities/:id流程:
app.delete('/api/account/external-identities/:id', requireAuth, async (req, res) => {
await requireRecentReauth(req.user.id)
const loginMethods = await listLoginMethods(req.user.id)
if (loginMethods.length <= 1) {
return res.status(400).json({
code: 'LAST_LOGIN_METHOD',
message: 'Please add another login method before unlinking this one.',
})
}
await db.externalIdentity.delete({
where: {
id_userId: {
id: req.params.id,
userId: req.user.id,
},
},
})
await auditLog('external_identity_unlinked', {
userId: req.user.id,
externalIdentityId: req.params.id,
})
res.status(204).send()
})十四、 常見錯誤
1. email 一樣就自動綁定
錯誤:
Google email == user.email,所以直接登入 user。正確:
先查 external identity。
若未綁定但 email 衝突,要求本地帳號驗證後再綁定。2. 不檢查 email_verified
錯誤:
Provider 有回 email 就信。正確:
若要用 email 輔助判斷,必須知道 Provider 是否驗證過該 email。3. 允許解除最後一個登入方式
錯誤:
使用者刪掉唯一的 Google 登入,從此無法登入。正確:
解除前檢查是否還有其他可用登入方式。4. 綁定不要求重新驗證
錯誤:
只要 session 還在,就可以新增第三方登入。正確:
帳號綁定是高風險操作,應要求近期重新驗證。5. External identity 可以被覆蓋
錯誤:
Google identity X 已綁 user A,user B 綁定時直接改成 user B。正確:
external identity 應有唯一鍵,已綁定時拒絕或進入嚴格轉移流程。6. 錯把登入流程和綁定流程共用
錯誤:
callback 只看 Provider,沒有 purpose。正確:
OAuth flow 應包含 purpose: login | signup | link | reauth。否則容易把「登入」誤處理成「綁定」,或把「綁定」誤處理成「登入」。
十五、 面試追問題庫
Q1:第三方登入 email 跟本地 email 一樣,可以自動綁定嗎?
不建議。
email 相同只能作為輔助訊號,不能直接代表使用者控制本地帳號。安全做法是先查 provider + issuer + subject 是否已綁定;若沒有,但 email 和既有帳號衝突,應要求使用者登入既有帳號、輸入密碼、通過 email OTP 或 MFA 後再綁定。
Q2:ExternalIdentity 為什麼不能只存 email?
因為 email 可能不存在、未驗證、可變更、被隱藏,也可能在不同 Provider 間重複。外部身份的穩定 key 應該是 Provider 提供的 subject 或 user id,搭配 provider 與 issuer。
Q3:已登入綁定為什麼比較安全?
因為使用者已經有本地 session,代表他目前控制本地帳號;接著完成 Provider 登入,代表他也控制外部身份。兩邊都確認後,綁定才比較合理。
Q4:解除綁定要注意什麼?
解除前要確認使用者仍有其他可用登入方式,例如密碼、其他 Provider、passkey 或 SSO。解除也應要求重新驗證、寄通知、記錄 audit log。
Q5:什麼是 pre-account takeover?
攻擊者先用受害者 email 建立本地帳號,之後受害者用第三方登入時,系統因 email 相同而把第三方身份綁到攻擊者預先建立的帳號。防法是未驗證 email 不可作為合併依據,第三方登入遇到既有帳號要要求本地帳號驗證。
Q6:如果外部身份已經綁到另一個 user,怎麼辦?
一般應拒絕綁定,提示該第三方帳號已被連結,但不要透露另一個 user 的 email 或身份。如果要支援轉移,需要非常嚴格的身份確認、通知、稽核與可能的 session 撤銷。
Q7:帳號綁定成功後要不要登出其他裝置?
看風險。一般綁定可只通知與記錄;若是高風險情境,例如異常裝置、剛完成帳號恢復、綁定高權限企業 SSO,則可以旋轉 session、撤銷其他裝置或要求 MFA。
十六、 實作題
題目一:設計帳號綁定狀態機
請設計以下狀態:
started -> provider_verified -> local_verified -> linked
started -> provider_verified -> conflict -> require_local_verification -> linked
started -> failed
started -> expired要求:
- flow 必須短效。
- state 必須一次性。
- provider identity 已被綁定時要拒絕。
- email 衝突時要要求本地驗證。
- 成功後寫 audit log。
題目二:設計解除綁定 API
要求:
- 必須登入。
- 必須近期重新驗證。
- 不能解除最後一個登入方式。
- 成功後寄通知。
- 記錄 audit log。
題目三:處理 email 衝突
情境:
user A 使用帳密註冊 alice@example.com,email_verified = true。
使用者從登入頁點 Google Login。
Google 回傳 alice@example.com,email_verified = true。
Google sub 尚未綁定。請設計安全流程。
好的回答應該包含:
- 不直接自動登入 user A。
- 建立 pending link intent。
- 要求本地密碼、email OTP 或 MFA。
- 驗證成功後綁定 Google sub。
- 建立本地 session。
- 寄通知並寫 audit log。
十七、 資深視角
1. 帳號綁定是身份所有權轉移
表面上你只是新增一筆 external identity。
實際上你是在說:
未來只要能控制這個 Google / LINE / GitHub 身份,
就能進入這個本地 user。這就是為什麼它要被視為高風險操作。
2. email 是屬性,不是身份主鍵
email 很好用,但它不是萬能身份主鍵。
在身份系統裡,更穩的模型是:
Identity 是誰。
Attribute 是關於這個人的資料。
Credential 是他如何證明自己。
Session 是他這次登入後的狀態。email 比較接近 attribute,不應單獨承擔 identity binding。
3. 不要把產品便利性建立在錯誤信任上
自動合併很順,但錯一次就是帳號接管。
更成熟的做法是:
- 低風險時給順暢的提示。
- 中風險時要求額外驗證。
- 高風險時要求強驗證或人工流程。
- 所有高風險操作都可追蹤、可通知、可撤銷。
4. SSO / 企業登入還要多一層 tenant
如果 Provider 是企業 IdP,帳號綁定還要處理:
- tenant id。
- organization id。
- domain verification。
- IdP policy。
- 管理員是否允許個人帳號綁定。
- 離職後 deprovision。
- SCIM / JIT provisioning。
這會在後面的 SSO、SAML 與 OIDC 文章展開。
總結
| 問題 | 回答重點 |
|---|---|
| 第三方身份主鍵 | provider + issuer + subject |
| email 能否自動合併 | 不建議,只能輔助且要 verified |
| 最安全綁定路徑 | 已登入本地帳號後主動綁定 Provider |
| email 衝突 | 要求本地帳號驗證後再綁定 |
| pre-account takeover | 攻擊者預先佔用 email,再等受害者第三方登入 |
| 解除綁定 | 不能解除最後一個登入方式 |
| 綁定是否高風險 | 是,應要求重新驗證、通知、稽核 |
| external identity 已被綁 | 拒絕或走嚴格轉移流程 |
一句完整的面試回答可以是:
第三方登入後的帳號綁定不能只靠 email 自動合併,因為 email 可能不存在、未驗證、可變更或被預先佔用。系統應使用 provider + issuer + subject 或 provider user id 作為 ExternalIdentity 的唯一識別,再對應本地 user。最安全的綁定流程是使用者先登入本地帳號,在帳號設定中主動新增第三方登入,並在近期重新驗證後完成 Provider 授權;如果使用者未登入但第三方 email 與既有帳號相同,應建立 pending link intent,要求本地密碼、email OTP、MFA 或登入既有帳號後再綁定。綁定與解除綁定都屬於高風險操作,應避免解除最後一個登入方式,並搭配通知、audit log、session 風險控制與唯一鍵約束,防止 account takeover。