← 返回博客

Rails 魔法登录链接(2026):用 HTTP 邮件 API 发信

在 Rails 里做无密码魔法登录:SecureRandom token 哈希落库、controller + service、用 Net::HTTP 调 POST /emails 并带 Idempotency-Key——优先 HTTP 而非 Action Mailer SMTP;Rovela 只负责投递。

更新于 2026年11月4日
Rails 魔法登录链接(2026):用 HTTP 邮件 API 发信

为什么用 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 / 魔法链接进垃圾箱 排查。

限流 / 单次 / 过期清单

  1. 发放限流 — 例如同一邮箱每分钟一条新链接;同时按 IP 封顶。
  2. 短 TTL — 登录约 15–30 分钟;二次验证可以更短。
  3. 落库只存哈希 — 仅 SHA-256 十六进制;明文只在邮件 URL。
  4. Upsert — 新请求作废该用户上一条未用哈希。
  5. 原子核销 — DELETE … RETURNING(或加锁 destroy),双击竞争安全失败。
  6. 防枚举 request — 不论地址是否存在,状态码与正文一致。
  7. Idempotency-Key — 每次逻辑发送稳定(magic- + 哈希前缀);新 token → 新 key。
  8. 防预取 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-256email:code 的 HMAC + pepper
常见 TTL~30 分钟~10 分钟
适合桌面/手机浏览器登录共享屏幕、二次验证

两者都调同一个 POST /emails。表与 service 分开——不要把 OTP 行当魔法链接 token 用。产品选型:验证码 vs 链接。其他栈的 OTP 姊妹篇:Express OTP · Django OTP。

上线清单

  1. 域名已验证;鉴权子域 SPF/DKIM/DMARC 正常。
  2. 该子域(或独立安全域)关闭点击追踪。
  3. 仅具备 sending_access 的 ROVELA_API_KEY 放在 credentials / 密钥管理——不要进前端包或公开「试一试」。
  4. MAGIC_FROM 用已验证子域;html + text 都有;纯文本含完整 URL。
  5. 每次逻辑发送带 Idempotency-Key;redeem 为删除并返回。
  6. request 防枚举;request(与 redeem)限流。
  7. 把发送 200 当作已入队;若需投递真相,用 webhook 看退信/投诉(Webhooks)。

本文不是什么

  • 不是完整生产级鉴权框架(OAuth2、Devise 深潜、MFA)。
  • 不是 NestJS、Next.js、FastAPI 或通用 魔法登录 的重复——那些分别讲 Nest 模块、App Router、FastAPI 路由与框架无关流程。
  • 不是通用 用 Ruby 发邮件 教程或 Action Mailer SMTP 配置复读。
  • 不是声称 Rovela 会存储、校验或过期你的 token。
  • 不是国内 ESP 对比,也不捏造产品功能。

下一步

  1. 按 密码重置示例 实现 token + Rails service。
  2. 用 POST /emails · Quick Start · Authentication 发送。
  3. 投递加固:OTP / 魔法链接进垃圾箱。
  4. UX 选型:验证码 vs 链接。
  5. 姊妹栈:NestJS 魔法登录 · Next.js 魔法登录 · FastAPI 魔法登录。
  6. 额度:Pricing。

一句话: 把魔法登录放进 Rails 的 controller + service:用 SecureRandom 生成 token 并哈希,再对 Rovela 发一次 Net::HTTP POST——这条路径优先 HTTP 而非 Action Mailer SMTP,安全域关闭点击追踪,也永远不要把邮件 API 当成身份提供方。