iT邦幫忙

2

無密碼登入不用買服務:用 AWS SES + Lambda 親手做 Magic Link 登入機制

  • 分享至 

  • xImage
  •  

tl;dr:不想為了 email 登入綁一個每月固定費用的寄信服務,可以用 AWS SES + Lambda + DynamoDB + API Gateway 自己做一套 Magic Link(無密碼)登入,寄信成本是線性的 $0.10 / 1,000 封、沒有月費門檻。這篇從資安概念講到完整架構,重點放在一個很多人會忽略的雷:DynamoDB 的 TTL 不能拿來當 token 的過期判斷

起因

toui.io 是我維護的短網址服務(名字是台語「佗位(tó-uī)」,意思是「哪裡」),它在上線前要做使用者登入,除了 Google 登入外,我還選了無密碼的 Magic Link 登入方式:使用者輸入 email、收一封含一次性連結的信、點了就登入,不用記密碼。

第一版我用現成的寄信服務串起來,跑得很好,而且免費使用也很香。但坐下來把預估上線後一年的寄信量算一算,發現費用曲線不太對——這類服務多半是「跨過免費額度就跳到每月 $20 起跳的方案」,雖然說多也不算太多,但只是為了登入就要再額外花費,每次有人登入都要心痛一下,這樣好像不太對。算完帳後決定:寄信這段自己用 AWS 做,成本從「月費」變回「用多少算多少」。整個搬遷花了一個下午,這篇就把這套架構完整寫出來。

先說清楚:這不是在diss任何寄信服務。如果你想要儘快上線、專注在服務核心,那些服務是好的選擇。只是當你開始預估「正式環境的真實量」時,算法就不同。

先搞懂:Magic Link 在資安上的意義

很多人對「沒有密碼反而比較安全」覺得反直覺,所以先把觀念講清楚。後面那些實作為什麼要這樣設計,原因其實都在這。

Magic Link 把「密碼」整個拿掉,好處是:

  • 你的資料庫裡沒有密碼可以被偷。 就算被脫庫,也沒有密碼雜湊外洩、沒有「使用者在別站重用同一組密碼」被波及的問題。
  • 其實你早就在用了。 幾乎每個網站的「忘記密碼」流程,本質就是一條寄到信箱的一次性連結=一條 Magic Link。既然密碼最終的後門就是 email,那不如讓 email 直接當主要憑證,少一個會被猜、被重用、被設太弱的密碼。
  • 代價是:你把信任收斂到「使用者的 email 帳號」這一個點。這點要顧好,但它本來就是你密碼重設的後門,並沒有變得更糟。

關鍵觀念:Magic Link 把「那條連結」變成了憑證本身。 所以那個 token 必須:

  1. 不可猜 — 用 crypto 等級的亂數產生,不要用流水號或可預測的 ID。
  2. 一次性 — 用過即作廢,而且「驗證」和「消耗」要是原子操作(後面 TTL 那段就是在講這件事最容易做錯的地方)。
  3. 短時效 — 給一個過期窗(我設 15 分鐘),逾時即廢。
  4. 傳遞通道是 email — 這是你不完全掌控的通道,所以上面三點一個都不能省。

一個進階的雷:有些企業的郵件安全閘道(防釣魚、防惡意連結那種)會自動預抓信裡的連結。如果你的驗證連結是「一點就消耗」,掃描器可能在使用者真的點之前就把一次性 token 點掉了。要做到很穩,可以在最後加一個「需要使用者實際按一下」的確認頁,而不是 GET 連結直接完成登入。

簡單說 Magic Link 不是「比較弱的登入」,而是把「密碼 + 忘記密碼」這條本來就存在的後門,整併成一條你掌控得住、也查得到紀錄的流程——前提是 token 的亂數、一次性、時效都做對。

為什麼自己用 AWS 做:成本

把寄信服務的方案門檻和 SES 的線性計費擺在一起看(數字以官方定價頁為準,以下為撰寫時的概況):

典型寄信服務:免費額度(約每月 3,000 封 / 每日 100 封)→ 跨過就跳到每月 $20 起的付費方案,再往上是 $35、$90⋯⋯一路階梯。重點是月費門檻:你用 10,001 封或 49,999 封,付的是同一筆方案費。

AWS SES:前 12 個月每月前 3,000 封 $0,之後 $0.10 / 1,000 封,線性、沒有月費門檻、沒有每月寄信上限。

抓幾個量級對比(左邊 SES、右邊是寄信服務的方案費):

每月寄信量 SES 寄信服務方案 倍數
10,000 $1.00 $20 約 20×
100,000 $10.00 $35 約 3.5×
500,000 $50.00 $350 約 7×

低量端那個 20× 不是因為 SES 多神,而是因為月費方案有 $20 的地板——一跨過免費額度就得付,跟你實際只用了一萬封還是四萬封無關。SES 跨過免費額度後是線性的。

還有一個容易忽略的點:每日寄信上限。短網址服務常有病毒式尖峰,一篇被分享的貼文可能一小時內灌進 200 個註冊。免費額度的「每日 100 封」會在這裡卡死;SES 沒有每月上限,每秒寄信速率會隨你的寄信信譽自動往上。

整體架構

整套 Magic Link 服務做成一個獨立的 AWS stack,跟主站(我的主站跑在 Cloudflare Worker 上)分開部署:

架構圖

兩朵雲之間唯一的橋,是那條帶 HMAC 簽章的轉址:AWS 這邊負責 token 生命週期,主站那邊負責 session。兩邊的程式碼都不需要持有對方雲的憑證。

動手做:各個元件

AWS SAM 把整包打包成一份 CloudFormation stack,sam deploy 一次原子部署。

DynamoDB 存 token。單一表,partition key 是 token(一個 UUID),billing 用 PAY_PER_REQUEST——在登入流量這種量級,帳單幾乎是零。每筆設一個 TTL 屬性 = now + 900(15 分鐘)。但這個 TTL 不是拿來判斷過期的(下一段細講)。

Lambda(Node.js 20.x、128MB、10s timeout)跑三條路由:

  • POST /send — 驗證 email、把 token 寫進 DynamoDB、呼叫 ses:SendEmail
  • GET /verify — 從 DynamoDB 讀 token、刪除、帶 HMAC 簽章轉址回主站
  • POST /send-email — 給非登入流程用的通用寄信(歡迎信、帳單、公告),由主站經 HMAC 呼叫

API Gateway(HTTP API v2) 放在 Lambda 前面。自訂網域、ACM 憑證、CORS 限定你的主站來源。節流設每路由 10 req/s burst、5 req/s sustained——順手擋掉對 /send 的濫用。

SES 負責真正寄信。驗證網域、自訂 MAIL FROM、DKIM CNAME + SPF + DMARC 在切換時一次設好(送達率全靠這幾項)。

Secrets ManagerHMAC_SECRET 等敏感值。Lambda 在冷啟動時讀一次、快取在 module-level 變數裡,熱呼叫就不用再付一次 API 呼叫(這點我第一版做錯了,見最後)。

最容易踩的雷:DynamoDB 的 TTL 不是安全邊界

這是整篇最值得注意的一點。

DynamoDB 的 TTL 功能盡力而為的背景清理,不是即時刪除。AWS 官方文件白紙黑字寫:過期項目會在「過期時間後的幾天內」被刪掉——實務上,一筆過了 TTL 的 row 可能還會在表裡留到約 48 小時

這代表什麼?如果你的 /verify 是「GetItem 讀得到就放行」、然後信任 DynamoDB 已經幫你把過期的掃掉了,那你等於把一條號稱 15 分鐘的 Magic Link,實際壽命拉長到最多約 48 小時。一條本該失效的登入連結,在這段時間裡還能用。

正確做法是:過期判斷放在 verify 當下做,TTL 只負責之後的清理。 兩者分開。

最乾淨的寫法,是把「新鮮度檢查」直接塞進那個原子的 DeleteItem 裡,用 ConditionExpression——這樣一條過期的 token 不可能同時通過驗證又被消耗掉

await dynamo.send(new DeleteItemCommand({
  TableName: process.env.TOKENS_TABLE!,
  Key: { token: { S: token } },
  ConditionExpression: '#ttl > :now',
  ExpressionAttributeNames: { '#ttl': 'ttl' },
  ExpressionAttributeValues: { ':now': { N: String(Math.floor(Date.now() / 1000)) } },
}));

如果條件不成立,這個 delete 會丟出 ConditionalCheckFailedException——接住它、轉址到失敗頁,結束。TTL 還是會在背景做它的事,那筆 row 最終會消失;但「15 分鐘 Magic Link」這個安全性質,是由 verify handler 保證的,不是由清理機制保證的

如果你不想用條件式寫入,至少也要在 GetItem 之後做一次明確檢查:

const ttl = Number(result.Item.ttl?.N || '0');
if (ttl < Math.floor(Date.now() / 1000)) {
  return failureRedirect();
}

這個「TTL 管整潔、條件式寫入管真正的過期」的拆分,是我之後處理任何短命 token 表都會預設採用的模式。

正式環境注意事項

SES 沙箱(sandbox):SES 帳號一開始在沙箱模式,只能寄給已驗證的地址,而且配額很低(每 24 小時 200 封、每秒 1 封)。要正式寄給任何人,得開支援單申請出沙箱,AWS 大約 24 小時內審。建議:建好帳號當天就申請,不要等到要上線那天才弄。

寄信信譽 / 暖機:拿到正式權限後,起始配額「依使用情境而定」(官方用語,實務上是一個會隨信譽成長的低起點)。對上線初期的量通常夠用;但如果你第一天就要寄給好幾千人,要提前規劃專用 IP 和暖機排程。

我會重做的一點:第一版我把 Secrets Manager 寫成「每次呼叫都去讀一次」,後來才發現有夠囉嗦——修法很簡單:冷啟動讀一次、快取在 module-level 變數,熱呼叫就跳過。AWS 自己的 Lambda + Secrets Manager 文件就是這個模式,從第一個 commit 就照做,不要像我到第三次部署才修。

收工

如果你在做側專案、還不知道自己的規模,用現成寄信服務沒有錯,但只要你的預估裡有一個會跨過「每月一萬封」的數字,就值得早點把這套自己搭起來——核心其實不複雜,難的是 TTL 那種「看起來會動、但安全性質是錯的」細節。

資源

  • DynamoDB TTL 運作方式:https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/howitworks-ttl.html
  • SES 寄信配額:https://docs.aws.amazon.com/ses/latest/dg/quotas.html
  • Lambda 讀取 Secrets Manager 的建議模式:https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html
  • AWS SAM:https://aws.amazon.com/serverless/sam/

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言