Rails 魔法登录链接(2026):用 HTTP 邮件 API 发信
在 Rails 里做无密码魔法登录:SecureRandom token 哈希落库、controller + service、用 Net::HTTP 调 POST /emails 并带 Idempotency-Key——优先 HTTP 而非 Action Mailer SMTP;Rovela 只负责投递。
为什么用 Rails + 纯 HTTP 邮件 API
Rails 做无密码登录本来就很顺:一个接收邮箱并返回统一文案「若该地址有账号…」的 controller、一个负责发 token 并 upsert 行的 service、以及一个核销 token 并建立 session 的 redeem action。你不需要 Rovela 的「魔法链接专用」Ruby gem,也不需要用 Action Mailer SMTP 把邮件服务商当成身份提供方,更不需要产品侧专门的登录链接接口。
你需要的是和 密码重置链接 同一套 token 配方:应用用 SecureRandom 生成一次性 token,只存 SHA-256,明文只出现在邮件 URL 里,再用 POST /emails 投递。Rovela 负责排队与 DKIM 签名;它不会签发 session,也不会校验 token。
Rovela 不提供魔法链接功能。 密码学、数据库行、过期、单次核销、session cookie 都归你的 Rails 应用。Rovela 只负责投递。
本文聚焦 Rails / Ruby(SecureRandom、Active Record、controller + service、Net::HTTP 调 Rovela),并优先用 HTTP API,而不是 Action Mailer SMTP 发鉴权邮件:一次 JSON POST、带 Idempotency-Key、应用服务器上不必挂本地 MTA。Nest 见 NestJS 魔法登录;App Router 见 Next.js 魔法登录;FastAPI 见 FastAPI 魔法登录;与框架无关的配方见 魔法登录邮件链接;验证码 vs 链接选型见 邮箱验证:验证码还是链接。通用 Ruby 发信见 用 Ruby 发邮件——本文不重复那篇入门发送。
你负责什么,Rovela 发什么
你的 Rails 应用负责:
SecureRandom.urlsafe_base64(32)(或 32 字节随机数再 URL-safe)作为明文 token- 数据库只存 token 的 SHA-256 十六进制(永不存明文)
- 过期时间(文档示例对重置类链接约 30 分钟)
- 你自己源站上的绝对 URL:
https://app.example.com/auth/magic?token=… - 原子核销(删除并返回)+ 建立 session
- 不论邮箱是否注册,对外响应保持一致
- 对 request / redeem 做限流
Rovela 负责:
POST https://api.rovela.dev/emails,Authorization: Bearer+ 具备sending_access的密钥(ROVELA_API_KEY放 credentials / ENV——不要提交进仓库,也不要暴露给浏览器)- 排队、签名、重试、退信/投诉处理
发送接口返回 200 只表示已接受并入队,不是「已进收件箱」。
凭证与精简 Rovela 客户端(Net::HTTP)
密钥放在 Rails.application.credentials 或环境变量,不要进仓库:
# config / ENV(示意)
# ROVELA_API_KEY=re_... # sending_access
# MAGIC_FROM=Acme <security@mail.acme.com>
# MAGIC_LINK_BASE_URL=https://app.example.com
# MAGIC_TTL_MINUTES=30
# app/services/rovela_client.rb
require "net/http"
require "json"
require "uri"
class RovelaClient
ENDPOINT = URI("https://api.rovela.dev/emails")
def self.send_email(to:, subject:, html:, text:, idempotency_key:)
api_key = ENV.fetch("ROVELA_API_KEY") # sending_access
from = ENV.fetch("MAGIC_FROM", "Acme <security@mail.acme.com>")
body = {
from: from,
to: to,
subject: subject,
html: html,
text: text
}
http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
http.use_ssl = true
http.open_timeout = 5
http.read_timeout = 20
req = Net::HTTP::Post.new(ENDPOINT)
req["Authorization"] = "Bearer #{api_key}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key
req.body = JSON.generate(body)
res = http.request(req)
unless res.is_a?(Net::HTTPSuccess)
Rails.logger.error("rovela send failed #{res.code} #{res.body}")
raise "rovela_send_failed"
end
JSON.parse(res.body)
end
end
这条路径为什么不用 Action Mailer + SMTP?Mailer 适合摘要、营销模板;魔法链接发送更需要稳定的 Idempotency-Key、同一 JSON 里同时带 html 与 text,以及应用机上不配 SMTP。在 service 里 HTTPS 调 Rovela;若你已有 Mailer,可继续给其他邮件用。鉴权见 Authentication,报文见 API: emails,首次发送见 Quick Start。
模型:只存哈希,短 TTL
# db/migrate/xxxx_create_magic_logins.rb
class CreateMagicLogins < ActiveRecord::Migration[7.1]
def change
create_table :magic_logins do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.string :token_hash, null: false, limit: 64
t.datetime :expires_at, null: false
t.timestamps
end
add_index :magic_logins, :token_hash, unique: true
end
end
# app/models/magic_login.rb
class MagicLogin < ApplicationRecord
belongs_to :user
end
高熵 URL-safe token 用纯 SHA-256 即可(六位 OTP 不同,需要 HMAC + pepper——见 验证码示例)。
Token 助手 + Service:创建、落库、发送
# app/services/magic_link_service.rb
require "digest"
require "securerandom"
class MagicLinkService
SAFE_MSG = "If that address has an account, a sign-in link is on its way."
def self.request_link(raw_email)
email = raw_email.to_s.strip.downcase
user = User.find_by(email: email)
if user
token = SecureRandom.urlsafe_base64(32)
token_hash = Digest::SHA256.hexdigest(token)
ttl = Integer(ENV.fetch("MAGIC_TTL_MINUTES", "30"))
expires_at = ttl.minutes.from_now
MagicLogin.upsert(
{
user_id: user.id,
token_hash: token_hash,
expires_at: expires_at,
updated_at: Time.current,
created_at: Time.current
},
unique_by: :user_id
)
base = ENV.fetch("MAGIC_LINK_BASE_URL").to_s.chomp("/")
link = "#{base}/auth/magic?token=#{token}"
send_magic_email(email, link, token_hash)
end
{ message: SAFE_MSG }
end
def self.redeem(token)
return nil if token.blank?
token_hash = Digest::SHA256.hexdigest(token)
# 一步核销,避免两次并行点击都成功。
# Postgres 可用 DELETE … RETURNING;否则用带锁的 Active Record 回退:
MagicLogin.transaction do
rec = MagicLogin.lock.find_by(token_hash: token_hash)
return nil unless rec && rec.expires_at > Time.current
uid = rec.user_id
rec.destroy!
uid
end
end
def self.send_magic_email(to, link, token_hash)
RovelaClient.send_email(
to: to,
subject: "Your Acme sign-in link",
html: <<~HTML,
<p>Use this link to sign in to Acme.</p>
<p><a href="#{link}">Sign in</a></p>
<p>The link works once and expires in 30 minutes.
If you did not request it, ignore this email.</p>
HTML
text: <<~TEXT,
Use this link to sign in to Acme.
Sign in: #{link}
The link works once and expires in 30 minutes.
TEXT
idempotency_key: "magic-#{token_hash[0, 32]}"
)
end
private_class_method :send_magic_email
end
务必同时发 html 与 text,并在纯文本里带完整 URL。鉴权邮件用已验证子域(如 security@mail.…)。Idempotency-Key 由 token 哈希派生,超时重试不会再入队一封;真正「重发」应换新 token、新 key。
Controller:request + redeem
# config/routes.rb
namespace :auth do
resource :magic, only: [], controller: "magic" do
post :request_link # POST /auth/magic/request_link
post :redeem # POST /auth/magic/redeem
end
end
get "/auth/magic", to: "auth/magic#show" # 落地页读取 ?token=
# app/controllers/auth/magic_controller.rb
module Auth
class MagicController < ApplicationController
skip_before_action :require_login, raise: false # 若你有登录过滤器
# Rails 8+ rate_limit,或用 rack-attack
def show
# 渲染页面:读取 params[:token],再 POST JSON 到 redeem。
# 不要在 GET 上建 session——邮件安全扫描常会预取链接。
end
def request_link
# 同时按邮箱 + IP 限流(rack-attack / Redis)。
payload = MagicLinkService.request_link(params.require(:email))
render json: payload
end
def redeem
token = params.require(:token)
user_id = MagicLinkService.redeem(token)
unless user_id
return render json: { error: "expired_or_used" }, status: :bad_request
end
user = User.find(user_id)
reset_session
session[:user_id] = user.id
render json: { ok: true }
end
end
end
优先用 POST redeem,不要在 GET 上直接建 session:邮件扫描器和预取器常会打 GET。若必须提供 GET 落地页,先展示「继续」,等用户明确操作后再签发 session。
若是邮箱归属确认(不是登录),同一 service 形状即可,换表与路径——见 邮箱验证链接。密码重置是文档里的孪生场景:安全的密码重置邮件。
登录 URL 请关闭点击追踪
打开/点击追踪是按域名配置的。开启点击追踪时,Rovela 会把链接改写到追踪域名。登录在跳转后仍可用,但 token 多了一跳。
对魔法链接(以及密码重置 / 邀请 token),建议单独的发送域或子域,并关闭点击追踪,让 token 直达你的 Rails 应用。这与 密码重置示例 的建议一致。若鉴权邮件仍进垃圾箱,按 OTP / 魔法链接进垃圾箱 排查。
限流 / 单次 / 过期清单
- 发放限流 — 例如同一邮箱每分钟一条新链接;同时按 IP 封顶。
- 短 TTL — 登录约 15–30 分钟;二次验证可以更短。
- 落库只存哈希 — 仅 SHA-256 十六进制;明文只在邮件 URL。
- Upsert — 新请求作废该用户上一条未用哈希。
- 原子核销 —
DELETE … RETURNING(或加锁 destroy),双击竞争安全失败。 - 防枚举 request — 不论地址是否存在,状态码与正文一致。
- Idempotency-Key — 每次逻辑发送稳定(
magic-+ 哈希前缀);新 token → 新 key。 - 防预取 redeem — 优先 POST;若必须 GET,先「继续」再显式建 session。
curl 等价(代理 / 冒烟)
与 Rails service 发出的报文相同——便于本地冒烟,也方便直接调 HTTP 的 AI agent(AI agent 发邮件):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: magic-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "alice@example.com",
"subject": "Your Acme sign-in link",
"html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
"text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
}'
仅在非生产环境把 TEST 换成真实 token。生产密钥需具备 sending_access;from 必须落在已验证域名上(Quick Start)。
同一 Rails 应用里:魔法链接 vs OTP
| 魔法链接 | OTP(六位数字) | |
|---|---|---|
| 文档形状 | 同 密码重置 | 验证码 |
| Rails 位置 | MagicLinkService + /auth/magic/* | 独立 OTP service + controller |
| 体验 | 一点即登 | 输入数字 |
| 存储 | 高熵 token 的 SHA-256 | email:code 的 HMAC + pepper |
| 常见 TTL | ~30 分钟 | ~10 分钟 |
| 适合 | 桌面/手机浏览器登录 | 共享屏幕、二次验证 |
两者都调同一个 POST /emails。表与 service 分开——不要把 OTP 行当魔法链接 token 用。产品选型:验证码 vs 链接。其他栈的 OTP 姊妹篇:Express OTP · Django OTP。
上线清单
- 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
- 该子域(或独立安全域)关闭点击追踪。
- 仅具备
sending_access的ROVELA_API_KEY放在 credentials / 密钥管理——不要进前端包或公开「试一试」。 MAGIC_FROM用已验证子域;html + text 都有;纯文本含完整 URL。- 每次逻辑发送带
Idempotency-Key;redeem 为删除并返回。 - request 防枚举;request(与 redeem)限流。
- 把发送
200当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。
本文不是什么
- 不是完整生产级鉴权框架(OAuth2、Devise 深潜、MFA)。
- 不是 NestJS、Next.js、FastAPI 或通用 魔法登录 的重复——那些分别讲 Nest 模块、App Router、FastAPI 路由与框架无关流程。
- 不是通用 用 Ruby 发邮件 教程或 Action Mailer SMTP 配置复读。
- 不是声称 Rovela 会存储、校验或过期你的 token。
- 不是国内 ESP 对比,也不捏造产品功能。
下一步
- 按 密码重置示例 实现 token + Rails service。
- 用 POST /emails · Quick Start · Authentication 发送。
- 投递加固:OTP / 魔法链接进垃圾箱。
- UX 选型:验证码 vs 链接。
- 姊妹栈:NestJS 魔法登录 · Next.js 魔法登录 · FastAPI 魔法登录。
- 额度:Pricing。
一句话: 把魔法登录放进 Rails 的 controller + service:用 SecureRandom 生成 token 并哈希,再对 Rovela 发一次 Net::HTTP POST——这条路径优先 HTTP 而非 Action Mailer SMTP,安全域关闭点击追踪,也永远不要把邮件 API 当成身份提供方。