mcpField
M
magic-link-auth

magic-link-auth

github.com/thinkoodle安全7160 次安装7 周前更新
代码仓库

关于 magic-link-auth

用于在 Rails 中构建无密码认证的技能,采用邮件验证码与令牌。内容说明如何将验证绑定到请求邮箱,并涵盖账号枚举风险、限流、会话和测试。

主要功能

  1. 01邮件一次性验证码
  2. 02待验证令牌绑定邮箱
  3. 03验证码使用后即销毁
  4. 04可撤销的数据库会话
  5. 05限流与安全测试

适用场景

  1. 01为 Rails 应用添加无密码登录
  2. 02降低登录流程中的邮箱枚举风险
  3. 03支持浏览器和原生应用认证流程

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill magic-link-auth

安装后在 Claude Code 中输入/magic-link-auth确认已加载。

附带文件reference.md

详细介绍

魔法链接 / 令牌-代码认证

无密码的 Rails 认证:用户输入邮件中的短代码;浏览器/API 持有令牌。这是 Basecamp/Fizzy 模式(Herald 也使用)。它不是 bin/rails generate authentication,也不是 Devise。

理念

  1. 代码供人使用,令牌供机器使用。 混淆两者是最常见的实现错误。
  2. 无论邮箱是否存在,都返回相同的响应。 通过“未找到账号”枚举用户是安全漏洞。
  3. 将代码步骤绑定到请求代码的邮箱。 攻击者输入的 Alice 邮箱代码不能让 Bob 登录。
  4. 使用后销毁代码。销毁会话。 重复使用是错误;登出必须真正退出登录。
  5. 身份负责认证,用户属于租户。 不要将会话挂在租户成员记录上。

四种密钥

名称持有者有效期格式
代码用户,通过邮件获取约 15 分钟,单次使用6 个 Crockford base32 字符
待处理认证令牌Cookie(原生应用也会在 JSON 请求体中携带)与代码相同邮箱的消息验证器
会话令牌已签名 Cookie直到 Session 行被销毁session.signed_id
访问令牌(可选)Authorization: Bearer直到撤销has_secure_token

不要凭空增加第五种密钥。除非你已经这样做了,否则不要将会话令牌存为自定义随机列——Fizzy 使用 Active Record signed_id,没有 sessions.token 列。

流程

POST /session          email
  → find identity (or fake it)
  → create MagicLink, email the code
  → set pending_authentication_token cookie
  → redirect to code form

POST /session/magic_link   code
  → MagicLink.consume(code)
  → secure_compare pending email vs identity email
  → Session.create! + session_token cookie
  → clear pending token

Later requests
  → Session.find_signed(cookies.signed[:session_token])
  → or Bearer access token

原生应用使用 JSON 执行相同的两次 POST 请求,并通过 Cookie 发回待处理令牌。API 合约见 reference.md。

第 1 步:检查现有实现

ls app/models/identity.rb app/models/magic_link.rb app/models/session.rb
ls app/controllers/concerns/authentication.rb
rg "has_secure_password|devise|generate authentication" app/ config/

如果应用已有这种模式,就在其基础上扩展——不要另行接入 Devise 或 Rails 8 密码生成器。

第 2 步:模型

Identity 表示用户。邮箱唯一、经过规范化,是唯一的登录标识符。

class Identity < ApplicationRecord
  has_many :magic_links, dependent: :destroy
  has_many :sessions, dependent: :destroy
  has_many :access_tokens, dependent: :destroy

  validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
  normalizes :email_address, with: ->(value) { value.strip.downcase.presence }

  def send_magic_link(**attributes)
    attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)

    magic_links.create!(attributes).tap do |magic_link|
      MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
    end
  end
end

MagicLink 表示代码。代码必须唯一、会过期,并在使用后通过销毁来消费。

class MagicLink < ApplicationRecord
  CODE_LENGTH = 6
  EXPIRATION_TIME = 15.minutes

  belongs_to :identity
  enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in

  scope :active, -> { where(expires_at: Time.current...) }
  scope :stale,  -> { where(expires_at: ..Time.current) }

  before_validation :generate_code, on: :create
  before_validation :set_expiration, on: :create
  validates :code, uniqueness: true, presence: true

  def self.consume(code)
    active.find_by(code: Code.sanitize(code))&.consume
  end

  def self.cleanup
    stale.delete_all
  end

  def consume
    destroy
    self
  end

  private
    def generate_code
      self.code ||= loop do
        candidate = MagicLink::Code.generate(CODE_LENGTH)
        break candidate unless self.class.exists?(code: candidate)
      end
    end

    def set_expiration
      self.expires_at ||= EXPIRATION_TIME.from_now
    end
end

代码清理——用户输入时可能会混淆 O/0 和 I/L/1。应在消费时清理,而不是生成时清理。

module MagicLink::Code
  CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze

  def self.generate(length)
    SecureRandom.base32(length)
  end

  def self.sanitize(code)
    return if code.blank?

    code.to_s.upcase
      .then { |c| CODE_SUBSTITUTIONS.reduce(c) { |result, (from, to)| result.gsub(from, to) } }
      .gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "")
  end
end

Session 表示已登录的浏览器/应用。不设令牌列。

class Session < ApplicationRecord
  belongs_to :identity
end

Cookie 值是 session.signed_id。通过 Session.find_signed(cookies.signed[:session_token]) 查找。销毁该行即可让该设备退出登录。

第 3 步:迁移

create_table :identities, id: :uuid do |t|
  t.string :email_address, null: false
  t.timestamps
end
add_index :identities, :email_address, unique: true

create_table :magic_links, id: :uuid do |t|
  t.references :identity, type: :uuid, null: false, foreign_key: true
  t.string :code, null: false
  t.integer :purpose, null: false, default: 0
  t.datetime :expires_at, null: false
  t.timestamps
end
add_index :magic_links, :code, unique: true
add_index :magic_links, :expires_at

create_table :sessions, id: :uuid do |t|
  t.references :identity, type: :uuid, null: false, foreign_key: true
  t.string :user_agent
  t.string :ip_address
  t.timestamps
end

使用 expires_at(建索引,便于清理)。不要只根据 created_at 推算过期时间。除非需要审计记录,否则不要用 consumed_at 保留已消费的代码——Fizzy 会销毁它们。

第 4 步:认证 Concern

每次请求都恢复会话。默认要求会话。登录控制器则选择退出这一要求。

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?, :email_address_pending_authentication
    include Authentication::ViaMagicLink
  end

  class_methods do
    def require_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
      before_action :resume_session, **options
      before_action :redirect_authenticated_user, **options
    end
  end

  private
    def authenticated?
      Current.identity.present?
    end

    def require_authentication
      resume_session || authenticate_by_bearer_token || request_authentication
    end

    def resume_session
      if session = find_session_by_cookie
        set_current_session session
      end
    end

    def find_session_by_cookie
      Session.find_signed(cookies.signed[:session_token])
    end

    def authenticate_by_bearer_token
      return unless request.authorization.to_s.include?("Bearer")

      authenticate_or_request_with_http_token do |token|
        if identity = Identity.find_by_permissible_access_token(token, method: request.method)
          Current.identity = identity
        end
      end
    end

    def request_authentication
      session[:return_to_after_authenticating] = request.url
      redirect_to new_session_path
    end

    def start_new_session_for(identity)
      identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
        set_current_session session
      end
    end

    def set_current_session(session)
      Current.session = session
      cookies.signed.permanent[:session_token] = {
        value: session.signed_id, httponly: true, same_site: :lax
      }
    end

    def terminate_session
      Current.session.destroy
      cookies.delete(:session_token)
    end

    def redirect_authenticated_user
      redirect_to root_path if authenticated?
    end
end

Current.session= 还应设置 Current.identity。有关 Current、Bearer 查找和仅展示一次的访问令牌,请参阅 reference.md。

第 5 步:待处理令牌 + 防枚举

待处理令牌是消息验证器,不是数据库行。它存储邮箱,并与代码同时过期。代码表单绝不接受邮箱参数。

module Authentication::ViaMagicLink
  extend ActiveSupport::Concern

  included do
    after_action :ensure_development_magic_link_not_leaked
  end

  private
    def redirect_to_fake_session_magic_link(email_address)
      fake = MagicLink.new(
        identity: Identity.new(email_address: email_address),
        code: SecureRandom.base32(6),
        expires_at: MagicLink::EXPIRATION_TIME.from_now
      )
      redirect_to_session_magic_link fake
    end

    def redirect_to_session_magic_link(magic_link)
      serve_development_magic_link(magic_link)
      set_pending_authentication_token(magic_link)

      respond_to do |format|
        format.html { redirect_to session_magic_link_path }
        format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created }
      end
    end

    def set_pending_authentication_token(magic_link)
      cookies[:pending_authentication_token] = {
        value: pending_authentication_token_verifier.generate(
          magic_link.identity.email_address, expires_at: magic_link.expires_at
        ),
        httponly: true, same_site: :lax, expires: magic_link.expires_at
      }
    end

    def email_address_pending_authentication
      pending_authentication_token_verifier.verified(pending_authentication_token)
    end

    def pending_authentication_token_verifier
      Rails.application.message_verifier(:pending_authentication)
    end

    def pending_authentication_token
      cookies[:pending_authentication_token]
    end

    def clear_pending_authentication_token
      cookies.delete(:pending_authentication_token)
    end

    def serve_development_magic_link(magic_link)
      if Rails.env.development? && magic_link.present?
        flash[:magic_link_code] = magic_link.code
        response.set_header("X-Magic-Link-Code", magic_link.code)
      end
    end

    def ensure_development_magic_link_not_leaked
      unless Rails.env.development?
        raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
      end
    end
end

对于未知邮箱,走 redirect_to_fake_session_magic_link。重定向相同,待处理 Cookie 相同,不发送邮件,也不创建 Identity。

第 6 步:控制器

对两个步骤都进行速率限制。真正的攻击是暴力破解代码。

class SessionsController < ApplicationController
  require_unauthenticated_access except: :destroy
  rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded

  def create
    if identity = Identity.find_by(email_address: email_address)
      redirect_to_session_magic_link identity.send_magic_link
    else
      redirect_to_fake_session_magic_link email_address
    end
  end

  def destroy
    terminate_session
    redirect_to new_session_path
  end

  private
    def email_address
      params.expect(:email_address)
    end
end
class Sessions::MagicLinksController < ApplicationController
  require_unauthenticated_access
  rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded
  before_action :ensure_pending_email

  def create
    if magic_link = MagicLink.consume(code)
      authenticate(magic_link)
    else
      redirect_to session_magic_link_path, flash: { shake: true }
    end
  end

  private
    def authenticate(magic_link)
      if ActiveSupport::SecurityUtils.secure_compare(
        email_address_pending_authentication || "", magic_link.identity.email_address
      )
        clear_pending_authentication_token
        start_new_session_for magic_link.identity
        redirect_to after_authentication_url
      else
        clear_pending_authentication_token
        redirect_to new_session_path, alert: "Something went wrong. Please try again."
      end
    end

    def code
      params.expect(:code)
    end
end

代码错误:停留在表单页面,不要说明原因。代码属于其他用户:销毁待处理令牌并从头开始。绝不要为与待处理邮箱不匹配的代码创建会话。

开放注册:先使用 find_or_create_by!,再调用 send_magic_link(for: :sign_up);用户通过代码验证后,再引导新用户进入完成步骤(姓名、账号),而不是之前。关闭注册:走伪造流程,不创建 Identity。

第 7 步:路由、邮件程序、视图、日志记录

resource :session do
  scope module: :sessions do
    resource :magic_link, only: %i[show create]
  end
end

将代码放在主题中,以便用户能从通知中看到:“Your App code is #{magic_link.code}”。正文中也要用大号字体重复显示。注明有效期为 15 分钟。

代码字段:autocomplete: "one-time-code"、maxlength: 6,按 Enter 键或粘贴时提交。在页面上显示待验证的邮箱,让用户知道邮件发到了哪里。仅在开发环境中闪现代码。

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += %i[ passw secret token _key crypt salt otp code ]

安排 MagicLink.cleanup(使用 Solid Queue recurring,或 rake 任务)。过期记录是已过期但从未使用的代码。

必须具备的测试

通过提交真实流程来登录,不要填入 Current:

def sign_in_as(identity)
  identity.send_magic_link
  post session_path, params: { email_address: identity.email_address }
  post session_magic_link_path, params: { code: identity.magic_links.last.code }
end

覆盖以下情况:有效代码会设置 session_token 并销毁 MagicLink;无效或过期代码不会;其他邮箱的代码不会生效;未知邮箱仍会渲染代码表单;缺少待验证令牌时会重定向到登录页;速率限制;开发环境中的代码闪现不会出现在测试或生产环境中。

反模式

  1. 在验证表单的查询参数中传递邮箱——跳过待验证令牌后,任何持有代码的人都能绑定任意邮箱。Fizzy 通过验证器 cookie 进行绑定。
  2. 每次登录都使用 find_or_create_by——这会为攻击者临时使用的邮箱创建身份。只能在明确的注册流程中创建。
  3. 告知用户该邮箱不存在——使用虚假的 magic link 重定向。
  4. 使用没有额外控制措施的 6 位数字代码——代码空间仅有 1e6,没有严格的速率限制就容易遭受暴力破解。建议使用 base32(32^6)并添加速率限制。
  5. 将 has_secure_password 与此流程一起使用——这会引入两套身份验证系统。二选一。
  6. 将会话令牌存储在 Rails session cookie store 中——Session 记录才是事实来源,这样才能撤销设备的访问权限。
  7. 多次显示访问令牌——生成令牌后,通过短期有效的签名 ID 显示,之后不再显示。
  8. 跳过对待验证邮箱的 secure_compare——使用 ActiveSupport::SecurityUtils.secure_compare。
  9. 使用后仍将代码留在数据库中——通过 destroy 消费代码(如果必须进行审计,则使用 consumed_at)。
  10. 在开发环境之外闪现代码——设置 after_action raise 正是为了防止这种情况发生。

参考

参阅 reference.md,了解 Identity 与 User(另见 identity-membership 技能)、Herald/Mosaic 变体、JSON/原生应用契约、个人访问令牌、Current 属性和测试辅助工具。

评论

支持 Markdown