帳號密碼登入流程
帳號密碼登入看起來是最普通的功能:輸入 email、輸入 password、按下登入。但真正的登入流程不是「查到 user 且密碼正確就回 token」這麼簡單。成熟系統還要處理錯誤訊息、帳號狀態、防暴力破解、Session / Token、MFA、稽核紀錄、裝置資訊與安全事件。
一句話回答:帳號密碼登入流程是:前端透過 HTTPS 送出帳號密碼,後端標準化帳號、查詢使用者、用安全的 password hash 驗證密碼,通過後檢查帳號狀態與風險策略,必要時進入 MFA;最後建立 session 或簽發 token,設定安全 cookie 或回傳登入憑證,同時寫入 login audit log,並用統一錯誤訊息避免帳號枚舉。
一、 必考觀念
1. 登入流程不是只有密碼比對
完整登入通常包含:
輸入帳密
-> 基本格式檢查
-> 後端標準化帳號
-> 查詢使用者
-> 驗證密碼
-> 檢查帳號狀態
-> 檢查風險與限制
-> 需要 MFA 時進入第二步
-> 建立 session / token
-> 回應前端
-> 寫入稽核紀錄如果只回答「查資料庫,比對密碼,回 JWT」,會少掉很多安全與產品邊界。
2. 帳密登入解決的是 Authentication
帳號密碼登入主要在做身份驗證:
這次登入者是否知道該帳號的正確秘密?
它不直接解決:
- 使用者是否完成信箱驗證。
- 使用者是不是自然人本人。
- 使用者能不能進後台。
- 使用者能不能操作某個 API。
這些分別屬於身份識別與授權。
延伸讀:身份驗證、授權與身份識別的差異。
3. 密碼不能明文儲存
資料庫不應該有:
email: user@example.com
password: 12345678應該存的是 password hash:
email: user@example.com
passwordHash: $argon2id$v=19$m=...登入時不是把 hash 解密回密碼,而是:
使用者輸入 password
-> 用相同演算法驗證 password 是否符合 passwordHash密碼儲存會在後面獨立成篇。這篇先記住:密碼 hash 是登入系統的底線,不是加分項。
二、 登入流程總覽
1. Sequence Diagram
失敗時也要寫紀錄:
login_failed
rate_limited
account_locked
mfa_required
login_success2. API 形狀
常見請求:
POST /api/login
Content-Type: application/json
{
"identifier": "user@example.com",
"password": "user-input-password",
"rememberMe": true
}identifier 可以是:
- email。
- username。
- phone。
- tenant + username。
不要在前端把欄位命名成過度固定的 email,除非產品確定永遠只支援 email 登入。
3. 回應可以是 Session 或 Token
Session cookie 版本:
HTTP/1.1 200 OK
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/
Content-Type: application/json
{
"user": {
"id": "user_123",
"email": "user@example.com",
"emailVerified": true
}
}Token 版本:
{
"accessToken": "eyJ...",
"refreshToken": "opaque-refresh-token",
"user": {
"id": "user_123",
"email": "user@example.com"
}
}Session / Token 的選型不是這篇重點,但登入流程必須明確知道自己建立的是哪一種登入狀態。
延伸讀:HTTP 專題:Session 與 Token 的抉擇。
三、 前端登入表單
1. 前端只做體驗檢查
前端可以檢查:
- 帳號欄位不能空。
- 密碼欄位不能空。
- email 格式看起來合理。
- 按鈕 loading 時不能重複送出。
但真正安全檢查一定在後端。
async function submitLogin() {
if (!identifier.value || !password.value) {
formError.value = '請輸入帳號與密碼'
return
}
submitting.value = true
formError.value = ''
try {
await authApi.login({
identifier: identifier.value,
password: password.value,
rememberMe: rememberMe.value,
})
router.push(redirect.value || '/')
} catch (error) {
formError.value = normalizeLoginError(error)
} finally {
submitting.value = false
}
}前端驗證是為了減少無效請求與改善體驗,不是安全邊界。
2. 不要在前端保存密碼
避免:
- 把 password 放 localStorage。
- 把 password 放 URL query。
- 把 password 打進 console。
- 把 password 塞進 analytics event。
- 登入失敗後把 password 存進全域 store。
密碼只應該短暫存在表單狀態中,送出後就應該盡量縮短它在前端的生命週期。
3. 錯誤訊息要兼顧安全與體驗
不建議:
這個 email 不存在
密碼錯誤這會讓攻擊者判斷帳號是否存在。
更好的訊息:
帳號或密碼錯誤但也不能所有情況都含糊到使用者無法行動。可以分層:
| 情況 | 建議訊息 |
|---|---|
| 帳密錯誤 | 帳號或密碼錯誤 |
| 次數太多 | 嘗試次數過多,請稍後再試 |
| 需要 MFA | 請完成第二步驗證 |
| 帳號停用 | 帳號無法登入,請聯絡客服或管理員 |
| 系統錯誤 | 登入暫時不可用,請稍後重試 |
OWASP Authentication Cheat Sheet 也建議登入、忘記密碼等認證流程使用通用錯誤訊息,以降低帳號枚舉風險。
四、 後端登入流程
1. 標準化帳號
後端收到 identifier 後先標準化:
function normalizeIdentifier(value: string) {
return value.trim().toLowerCase()
}但要注意:
- email 大小寫處理要符合產品規則。
- username 是否大小寫敏感要先定義。
- 手機號碼要考慮國碼格式。
- 多租戶系統可能還需要 tenant id。
標準化不是越多越好,而是要和帳號唯一性規則一致。
2. 查詢使用者
const identifier = normalizeIdentifier(input.identifier)
const user = await userRepository.findByIdentifier(identifier)查詢時要避免:
- SQL injection。
- 把完整 user 物件打進 log。
- 回傳不存在與密碼錯誤的不同訊息。
- 忘記處理 disabled / deleted / pending user。
3. 驗證密碼
概念上:
const passwordValid = await passwordHasher.verify({
password: input.password,
passwordHash: user.passwordHash,
})密碼驗證要使用適合密碼的 hash / KDF,例如 Argon2id、bcrypt、scrypt、PBKDF2 等,並搭配 salt 與合理成本參數。NIST SP 800-63B 和 OWASP 都強調密碼應使用合適的 password hashing 方案,而不是一般雜湊如單純 SHA-256。
不建議:
hash(input.password) === user.passwordHash更不應該:
decrypt(user.password) === input.password密碼 hash 的細節下一篇會完整整理。
4. 避免帳號枚舉與時間差異
如果帳號不存在時立刻回應,而帳號存在但密碼錯誤時需要跑 hash 驗證,攻擊者可能透過回應時間推測帳號是否存在。
可以用 dummy hash 降低差異:
const dummyPasswordHash = '$argon2id$v=19$m=...'
const user = await userRepository.findByIdentifier(identifier)
const passwordHash = user?.passwordHash ?? dummyPasswordHash
const passwordValid = await passwordHasher.verify({
password: input.password,
passwordHash,
})
if (!user || !passwordValid) {
throw new InvalidLoginError()
}這不代表完全消除所有側信道,但能避免最粗糙的時間差異。
5. 檢查帳號狀態
密碼正確後還要檢查:
| 狀態 | 處理 |
|---|---|
| pending email verification | 視產品規則,要求先驗證信箱或限制功能 |
| disabled | 拒絕登入,提示聯絡管理員 |
| locked | 拒絕登入,提示稍後或走恢復流程 |
| password expired | 要求修改密碼,通常不建議任意週期強制更換 |
| mfa enabled | 進入 MFA challenge |
| high risk login | 要求 step-up 或額外驗證 |
這些狀態最好有明確的狀態機,不要用一堆布林亂疊。
五、 建立登入狀態
1. Session Cookie
Session-based login 會在伺服器建立 session:
const session = await sessionService.create({
userId: user.id,
ip: request.ip,
userAgent: request.headers['user-agent'],
rememberMe: input.rememberMe,
})回應:
reply.setCookie('sid', session.id, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: session.maxAge,
})重點:
HttpOnly降低 token 被 JS 讀取的風險。Secure要求 HTTPS。SameSite降低 CSRF 風險。- session id 要有足夠隨機性。
- session 存放在 Redis / DB 等 server-side store。
2. Access Token / Refresh Token
Token-based login 常見做法:
const accessToken = tokenService.createAccessToken({
sub: user.id,
sessionId: session.id,
})
const refreshToken = await tokenService.createRefreshToken({
userId: user.id,
sessionId: session.id,
})重點:
- access token 短效。
- refresh token 長效但要可撤銷。
- refresh token 最好做 rotation。
- 登出時要撤銷 refresh token 或 session。
- 不要把敏感資料塞進 JWT payload。
延伸讀:HTTP 專題:JWT 深入剖析。
3. Remember me 不是無限登入
rememberMe 通常只是延長 session 或 refresh token 有效期。
要考慮:
- 一般登入有效多久。
- remember me 有效多久。
- 高風險操作是否仍要重新驗證。
- 密碼重設後是否撤銷所有 remember me sessions。
- 使用者能否在設定頁管理裝置。
不要把 remember me 做成永不過期的萬用 token。
六、 MFA 分流
1. 密碼正確不一定直接登入完成
如果帳號啟用了 MFA,流程可能是:
帳號密碼正確
-> 建立 login challenge
-> 回傳 mfaRequired
-> 使用者輸入 TOTP / SMS OTP / passkey
-> 驗證通過
-> 才建立正式 session回應可能是:
{
"status": "mfa_required",
"challengeId": "challenge_123",
"methods": ["totp", "backup_code"]
}此時不應該直接給完整 access token。
2. Login challenge 要短效
MFA challenge 要注意:
- 有效時間短。
- 綁定 user、裝置、IP 或瀏覽器上下文。
- 限制嘗試次數。
- 成功後立即失效。
- 失敗與成功都寫稽核紀錄。
否則攻擊者可能拿著半登入狀態繼續嘗試。
3. MFA 是提高保護,不是替代密碼安全
有 MFA 也不能:
- 明文存密碼。
- 不做 rate limit。
- 不處理帳號枚舉。
- 不撤銷 session。
- 不寫登入紀錄。
MFA 是第二道防線,不是讓第一道防線擺爛的理由。
七、 防暴力破解與帳號保護
1. Rate Limit
登入 API 一定要限流。
常見維度:
| 維度 | 目的 |
|---|---|
| IP | 限制同來源大量嘗試 |
| identifier | 防止針對單一帳號猜密碼 |
| IP + identifier | 更精準控制攻擊 |
| device fingerprint | 輔助判斷風險 |
| subnet / ASN | 對抗分散式攻擊時參考 |
簡化規則:
同一 IP 每分鐘最多 N 次登入
同一帳號每 15 分鐘最多 N 次失敗
超過門檻回 429 或要求額外驗證2. Account Lockout 要小心 DoS
帳號鎖定可以防猜密碼,但也可能被濫用:
攻擊者故意輸入某人的 email 多次錯密碼
-> 系統鎖住該帳號
-> 真正使用者無法登入所以可以改成:
- 漸進式延遲。
- 針對風險裝置要求 CAPTCHA。
- 對高風險嘗試增加 MFA。
- 暫時限制 IP 或裝置,而不是永久鎖帳號。
- 鎖定時提供安全的恢復流程。
3. Credential Stuffing
Credential stuffing 是攻擊者拿外洩帳密組合,到你的網站大量嘗試。
防護方式:
- rate limit。
- 檢查常見外洩密碼。
- 異常登入偵測。
- MFA。
- 新裝置通知。
- 失敗登入告警。
- 密碼重設與 session revoke。
這也是為什麼不要只看單次登入成功與否,還要看整體登入行為。
八、 稽核與監控
1. 登入事件要記錄
建議記錄:
| 欄位 | 說明 |
|---|---|
| eventType | login_success、login_failed、mfa_required |
| userId | 成功時可記,失敗時依風險處理 |
| identifierHash | 避免直接記明文帳號 |
| ip | 來源 IP |
| userAgent | 瀏覽器或裝置 |
| sessionId | 成功登入後的 session |
| reason | invalid_password、rate_limited、disabled |
| requestId | 對接 API log |
| createdAt | 發生時間 |
注意:不要把 password、token、完整 cookie 寫進 log。
2. 登入成功也可能是風險事件
要觀察:
- 新裝置登入。
- 新地點登入。
- 短時間多地登入。
- 大量失敗後突然成功。
- 休眠很久的帳號突然登入。
- 管理員帳號非工作時間登入。
這些不一定直接拒絕,但可能要:
- 通知使用者。
- 要求 MFA。
- 寫入高風險事件。
- 限制敏感操作。
3. 安全事件要能追
當使用者說「我的帳號被盜」時,系統應該能回答:
什麼時間登入?
從哪個 IP?
哪個裝置?
是否通過 MFA?
建立了哪個 session?
登入後做了哪些敏感操作?
是否修改密碼或信箱?這就是為什麼登入流程要和 audit log、session 管理、風控串起來。
九、 常見錯誤
1. 告訴攻擊者帳號存在
不好的回應:
{
"error": "email_not_found"
}比較好的回應:
{
"error": "invalid_credentials",
"message": "帳號或密碼錯誤"
}內部 log 可以記原因,但外部不要暴露。
2. 登入成功後直接把整個 user 回前端
避免回傳:
- passwordHash。
- mfaSecret。
- resetToken。
- internal flags。
- security notes。
- 完整權限資料中不該暴露的部分。
建立一個安全的 user profile DTO:
type LoginUserProfile = {
id: string
email: string
emailVerified: boolean
displayName: string
}3. 沒有處理重複提交
使用者連點登入可能造成:
- 多個 session。
- 多次失敗計數。
- MFA challenge 重複建立。
- UI 狀態混亂。
前端要 disabled 按鈕,後端也要能處理重複請求。
4. 密碼錯誤也更新最後登入時間
lastLoginAt 應該只在成功登入後更新。
失敗登入可以記到:
lastFailedLoginAt
failedLoginCount
login_events不要把成功和失敗事件混在一起。
十、 面試回答框架
面試問「帳號密碼登入流程怎麼設計?」可以這樣答:
- 前端收集帳號密碼,做基本格式檢查,透過 HTTPS POST 到登入 API。
- 後端標準化 identifier,查詢使用者,但外部錯誤訊息保持通用,避免帳號枚舉。
- 用安全 password hash 驗證密碼,例如 Argon2id / bcrypt,不存明文密碼。
- 密碼正確後檢查帳號狀態、email verified、disabled、locked、MFA、風險策略。
- 通過後建立 session 或簽發 access / refresh token。
- 如果用 cookie,要設
HttpOnly、Secure、SameSite。 - 寫入 login audit log,包含 IP、userAgent、requestId、成功或失敗原因。
- 對登入 API 做 rate limit、失敗次數控制、異常登入偵測。
- 高風險操作仍需要重新驗證,不因為登入成功就永久信任。
十一、 實作題
題目:設計一個安全的 /login API
需求:
- 使用 email + password 登入。
- 不能暴露 email 是否存在。
- 支援 remember me。
- 支援 MFA 分流。
- 成功登入後建立 session cookie。
- 登入成功與失敗都要記錄。
簡化程式:
async function login(input: LoginInput, request: Request) {
const identifier = normalizeIdentifier(input.identifier)
await loginRateLimiter.check({
ip: request.ip,
identifier,
})
const user = await userRepository.findByIdentifier(identifier)
const passwordHash = user?.passwordHash ?? dummyPasswordHash
const passwordValid = await passwordHasher.verify({
password: input.password,
passwordHash,
})
if (!user || !passwordValid) {
await auditLog.write({
type: 'login_failed',
identifierHash: hashIdentifier(identifier),
ip: request.ip,
reason: 'invalid_credentials',
})
throw new InvalidCredentialsError()
}
if (user.status === 'disabled') {
await auditLog.write({
type: 'login_failed',
userId: user.id,
ip: request.ip,
reason: 'user_disabled',
})
throw new InvalidCredentialsError()
}
if (user.mfaEnabled) {
const challenge = await mfaService.createChallenge({
userId: user.id,
ip: request.ip,
userAgent: request.userAgent,
})
return {
status: 'mfa_required',
challengeId: challenge.id,
}
}
const session = await sessionService.create({
userId: user.id,
rememberMe: input.rememberMe,
ip: request.ip,
userAgent: request.userAgent,
})
await auditLog.write({
type: 'login_success',
userId: user.id,
sessionId: session.id,
ip: request.ip,
})
return {
status: 'success',
session,
user: toLoginProfile(user),
}
}這段程式的重點不是能直接貼上線上用,而是展示登入流程的責任邊界。
十二、 資深視角
1. 登入是安全流程,不是普通 CRUD
登入 API 不能只看成功路徑。
它還要考慮:
- 惡意流量。
- 帳號枚舉。
- 外洩密碼重放。
- session 固定攻擊。
- MFA 中斷流程。
- 裝置遺失。
- 密碼重設後撤銷舊 session。
- audit log 與告警。
越是基礎功能,越不能隨便。
2. 錯誤訊息要分內外
外部給使用者:
帳號或密碼錯誤內部 log 記:
identifier_not_found
invalid_password
user_disabled
rate_limited
mfa_failed這樣同時兼顧安全與除錯。
3. 登入成功只是信任的開始
登入後還要有:
- session 生命週期。
- refresh token 管理。
- 權限檢查。
- 高風險操作重新驗證。
- 裝置管理。
- 登出與撤銷。
- 異常登入通知。
不要把登入成功當作系統安全的終點。
總結
| 問題 | 回答重點 |
|---|---|
| 登入主流程 | 標準化帳號、查 user、驗密碼、檢查狀態、建立 session/token |
| 前端責任 | 基本檢查、loading、防重複提交、不保存密碼 |
| 後端責任 | 安全驗證、通用錯誤、rate limit、稽核、session 管理 |
| 密碼驗證 | 使用 password hash,不存明文、不自己發明演算法 |
| 錯誤訊息 | 外部通用,內部詳細 |
| MFA | 密碼正確後進入 challenge,不直接完成登入 |
| 防護 | rate limit、lockout 策略、credential stuffing 偵測 |
| 稽核 | 成功與失敗都要有 login event |
一句完整的面試回答可以是:
帳號密碼登入我會把它當安全流程設計。前端只做基本格式檢查、loading 和防重複提交,透過 HTTPS 把 identifier、password 送到後端。後端先標準化 identifier,查詢使用者,但對外統一回「帳號或密碼錯誤」,避免帳號枚舉;密碼用 Argon2id、bcrypt 這類 password hash 驗證,不能明文儲存。密碼正確後還要檢查帳號是否停用、鎖定、是否需要 MFA 或風險式驗證。通過後建立 session cookie 或簽發 access / refresh token,cookie 要設 HttpOnly、Secure、SameSite。登入成功和失敗都要寫 audit log,登入 API 要做 rate limit、失敗次數控制與異常登入監控。登入成功只是建立身份驗證狀態,後續 API 授權、高風險操作重新驗證、登出與 session 撤銷都要另外處理。