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. 01Rails アプリへのパスワードレスログイン追加
  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. **認証するのは Identity、テナントに所属するのは User です。**テナントのメンバーシップレコードにセッションを紐付けてはいけません。

4つのシークレット

名前保持する人/もの有効期間形式
コードメール経由で人間が保持約15分、1回限りCrockford base32 の6文字
保留中の認証トークンCookie(ネイティブアプリでは JSON 本文も)コードと同じメールのメッセージベリファイアー
セッショントークン署名付き CookieSession 行が破棄されるまでsession.signed_id
アクセストークン(任意)Authorization: Bearer失効するまでhas_secure_token

5つ目を作らないでください。すでにそうしている場合を除き、セッショントークンを独自のランダムなカラムに保存しないでください。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

ネイティブアプリでも同じ2つの POST を JSON として行い、保留中のトークンを 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 の定期実行、または 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 を使う — 攻撃者が使い捨てメールアドレスで ID を作成できてしまいます。明示的なサインアップフローでのみ作成します。
  3. メールアドレスが存在しないことをユーザーに伝える — 偽のマジックリンクへのリダイレクトを使います。
  4. 追加の対策なしで 6 桁の数字コードを使う — 組み合わせは 1e6 通りで、厳格なレート制限がなければ総当たり攻撃が可能です。レート制限と併せて base32(32^6)を使うことを推奨します。
  5. has_secure_password とこのフローを併用する — 認証システムが 2 つになります。どちらか一方を選びます。
  6. Rails のセッション Cookie ストアにセッショントークンを保存する — Session 行が信頼できる情報源であれば、デバイスを失効させられます。
  7. アクセストークンを複数回表示する — 生成後、短期間有効な署名付き ID を使って一度だけ表示し、その後は再表示しません。
  8. 保留中のメールアドレスに対する secure_compare を省略する — ActiveSupport::SecurityUtils.secure_compare を使います。
  9. 使用後もコードを DB に残す — destroy で消費済みにします(監査が必要な場合は consumed_at を使います)。
  10. 開発環境以外でコードをフラッシュメッセージに表示する — これが発生するのを防ぐため、after_action の raise が用意されています。

リファレンス

Identity と User の違い(identity-membership スキルも参照)、Herald/Mosaic の各バリエーション、JSON/ネイティブアプリの契約、個人用アクセストークン、Current の属性、テストヘルパーについては、reference.md を参照してください。

コメント