Rails 8の認証ジェネレータをマジックリンク(パスワードレス)ログインに改造する方法|Basecamp Fizzyの実装を読む

Rails 8の認証ジェネレータにマジックリンクを組み込んだログインフローの3ステップ図
  • このエントリーをはてなブックマークに追加

スポンサーリンク

Rails 8 の bin/rails generate authentication で認証を作ったあと、「パスワードを持たせるのやめたいな」と思ったことはありませんか?

パスワードは、持たせた瞬間に運用コストが発生します。ハッシュ化して保存し、リセットメールを送り、使い回しや流出に怯える。小さなチームだと、そこに割く体力がもったいないですよね。

そこで選択肢に上がるのがマジックリンク(パスワードレス)ログインです。パスワードという概念そのものを、アプリから消してしまう方式です。

この記事では、Rails 8 の認証ジェネレータで作った土台をマジックリンク方式に作り替える手順を、コード全部込みで解説します。実装の参考にするのは、37signals(Basecamp)が公開しているカンバンアプリ Fizzy の実際のソースコードです。「本番で動いているアプリがどう作っているか」をそのまま読み解いていきます。

マジックリンク方式とは?パスワード認証・メール認証との違い

実装に入る前に、そもそもマジックリンクが何なのかを整理しておきます。

マジックリンク認証は、「メールを受け取れること」そのものを本人確認の手段にする方式です。ログイン画面でメールアドレスを入力すると、使い捨ての認証情報(リンクまたはコード)がメールで届く。それを使ってログインする、という流れになります。

パスワードを保存しないので、「覚えてもらう」「リセットする」「流出に備える」という一連の仕事がまるごと消えます。

「メール認証」とは役割が別物です

名前が似ているので混同されがちですが、新規登録時のいわゆるメール認証(メールアドレス確認)とは目的が違います

メール認証(アドレス確認)マジックリンク認証
いつ使う新規登録のときだけログインのたびに毎回
目的そのアドレスが実在して本人のものか確かめる本人かどうか確かめてログインさせる
終わったあとverified_at などを立てて完了。以降は使わないセッションを発行してログイン状態にする
パスワード別途必要不要(これ自体が認証手段)

どちらも「メールを受け取れる人=本人とみなす」という前提は同じです。違うのは、1回きりの確認で役目を終えるのがメール認証、毎回のログイン手段として使い続けるのがマジックリンクという点です。

登録時のメール認証を実装したことがあるなら、あの仕組みを毎回のログインに転用するイメージが近いと思います。

メリットとデメリット

採用を判断するうえで、良い面と困る面を先に並べておきます。

メリットデメリット
パスワードを保存しない。DBが漏れてもパスワードは漏れず、リセット機能ごと不要になるログインのたびにメールを開く一手間。ただしログイン状態を維持すれば頻度は下げられる(後述
ユーザーが覚えるものがゼロ。「パスワードを忘れた」からの離脱が無くなるメールの遅延・迷惑メール判定でログインできなくなる。送信基盤の信頼性がそのままログインの可用性になる
実質的に2要素に近い。ログインにメールボックスへのアクセスが必要になるメールボックスを乗っ取られると突破される(ただしパスワードリセットも同じ弱点を持つ)

デメリットの2つ目は特に見落としがちです。パスワード認証なら「メールが届かない」はリセットのときだけの問題ですが、マジックリンクではメールが止まった瞬間に誰もログインできなくなります。SendGrid や SES など、送信基盤の監視はセットで考えてください。

1つ目のデメリットについては、ログイン状態をどれだけ維持できるか次第で印象がかなり変わります。Rails 8のジェネレータが実際に何日ログインを保つのかは、記事の後半で数字を出して整理します。

送るのは「リンク」と「コード」の2種類

もう一つ押さえておきたいのが、メールで送るものには2種類あることです。

  • リンク方式:クリックするとログインできるURLを送る
  • コード方式:6文字程度のワンタイムコードを送り、画面に入力してもらう

名前は「マジックリンク」ですが、実際にはコード方式を採っているサービスが多く、この記事でもコード方式で実装します。理由は後半の「リンク方式とコード方式、どちらを送るか」で詳しく説明します。

まず完成形:パスワードレスログインの流れ

細かい実装に入る前に、これから作るものの全体像を押さえておきましょう。ログインは3ステップになります。

ステップユーザーの操作サーバー側でやること
1メールアドレスを入力6文字のワンタイムコードを発行してメール送信。「このブラウザはこのメアドで認証待ち」という署名付きCookieを発行
2メールに届いたコードを入力コードを検索 → 見つかったら即座に削除(使い捨て)
3Cookieのメアドとコードの持ち主が一致するか照合してからログイン
Rails 8の認証ジェネレータにマジックリンクを組み込んだログインフローの3ステップ図
メールアドレス入力→コード入力→照合してログイン。3ステップ目の「そのコードを要求したブラウザか」の照合が安全性の肝になります。

ポイントは3つ目です。「コードが合っていればログイン」ではなく、「コードが合っている、かつ、そのコードを要求したブラウザである」ところまで確認します。ここを省くと、他人のコードを盗んだだけでログインできてしまいます。

Rails 8 の認証ジェネレータが生成する Session モデルや start_new_session_for はそのまま使えます。差し替えるのは「本人確認の方法」だけで、セッション管理の仕組みには手を入れません。

STEP1:Rails 8の認証ジェネレータで土台を作る

まずは普通に認証を生成します。

bin/rails generate authentication
bin/rails db:migrate

これで User / Session / Current モデル、SessionsControllerPasswordsController、そして認証の心臓部である Authentication concern が生成されます。生成物の詳細やDeviseとの使い分けはRails 8の認証ジェネレータとDeviseはどっちを使う?にまとめているので、そちらもどうぞ。

今回改造対象になるのは、この Authentication concern が持っている次のメソッドたちです。中身を思い出しておきましょう。

# app/controllers/concerns/authentication.rb(生成されたもの・抜粋)
def start_new_session_for(user)
  user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
    Current.session = session
    cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
  end
end

def after_authentication_url
  session.delete(:return_to_after_authenticating) || root_url
end

「ログインさせる処理」は start_new_session_for(user) がすでに面倒を見てくれています。私たちがこれから書くのは、この行にたどり着くまでの本人確認だけです。ここが分かっていると、以降のコードがぐっと読みやすくなります。

STEP2:Fizzyの実装を読む(本家はどう作っているか)

自分で書き始める前に、実際に運用されているコードを読んでおきましょう。Fizzy のマジックリンク関連は、ファイルが綺麗に分かれています。

ファイル役割
app/models/magic_link.rbコードの発行・有効期限・使い捨て
app/models/magic_link/code.rbコード文字列の生成と正規化
app/mailers/magic_link_mailer.rbコードをメールで送る
app/controllers/concerns/authentication/via_magic_link.rb認証待ち状態をCookieで持ち回る
app/controllers/sessions_controller.rbメアド受付 → コード発行
app/controllers/sessions/magic_links_controller.rbコード検証 → ログイン

※ Fizzy は複数アカウントに所属できるマルチテナント構成のため、ユーザーを表すモデルが Identity という名前になっています。以下、読み替えやすいように Identity = Rails 8 ジェネレータでいう User だと思って読んでください。

MagicLink モデル:6文字コード+15分+使い捨て

まず本体です。驚くほど短いので、そのまま引用します。

# Fizzy: app/models/magic_link.rb
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

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

    def cleanup
      stale.delete_all
    end
  end

  def consume
    destroy
    self
  end
end

設計の意図が読み取れるポイントを挙げると、こうなります。

  • コードは6文字・有効期限は15分。定数なので変えたければ1行で変えられる
  • consumedestroy してから自分自身を返す。つまり1回使ったら消える(リプレイ攻撃対策)
  • 検索は active スコープ経由。期限切れは find_by の時点でヒットしない
  • 期限切れは消さずに残るcleanup を定期実行して掃除する分業になっている

scope :active, -> { where(expires_at: Time.current...) }Time.current... という書き方は、終端を省略した Range です。「現在時刻より後」という条件を、SQLを意識せずに書けます。

MagicLink::Code:人間が読み間違えても通る「寛容な」コード

個人的に一番面白いのがこのファイルです。ユーザーがコードを打ち間違える前提で作られています。

# Fizzy: app/models/magic_link/code.rb
module MagicLink::Code
  CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze

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

    def sanitize(code)
      if code.present?
        normalize_code(code)
          .then { apply_substitutions(it) }
          .then { remove_invalid_characters(it) }
      end
    end

    private
      def normalize_code(code)
        code.to_s.upcase
      end

      def apply_substitutions(code)
        CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
      end

      def remove_invalid_characters(code)
        code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "")
      end
  end
end

SecureRandom.base32 が使う文字セットは Crockford Base32、つまり 0-9A-Z から I / L / O / U の4文字を除いたものです。1I0O のような見間違えやすい文字を最初から発行しないという発想ですね。

そのうえで sanitize が、ユーザーの入力を寛大に受け止めます。小文字は大文字に、O と打たれたら 0 に、IL1 に読み替え、ハイフンや空白は捨てる。

実際、Fizzy のテストには MagicLink::Code.sanitize("OIL123")"011123" になる、というアサーションが書かれています。メールからコピペしたときの余計な空白でログインに失敗する、といったストレスをここで吸収しているわけです。

pending_authentication_token:ここが一番の肝

マジックリンクを自作するとき、多くの人が抜かしてしまうのがこの部分です。

「メールアドレスを入力した」という状態は、次のリクエストまで覚えておく必要があります。かといって、生のメールアドレスを普通の session に置くだけでは、有効期限の管理もできません。Fizzy は期限付きの署名済みトークンをCookieに入れて持ち回っています。

# Fizzy: app/controllers/concerns/authentication/via_magic_link.rb(抜粋)
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

Rails.application.message_verifier は、Rails が標準で持っている署名機能です。generate で署名し、verified で検証する。改ざんされていたり期限切れだったりすれば verifiednil を返してくれるので、こちら側で例外処理を書く必要もありません。

この仕組みがあることで「コードを発行したブラウザ」と「コードを入力したブラウザ」が同一かどうかを判定できます。マジックリンクの安全性を支えているのは、実はコードそのものよりこのCookieのほうです。

検証側:コードとメールアドレスの両方を照合する

そして検証。MagicLink.consume でコードを引き当てたあと、もう一段の照合が入っているのが分かります。

# Fizzy: app/controllers/sessions/magic_links_controller.rb(抜粋)
def create
  if magic_link = MagicLink.consume(code)
    authenticate magic_link
  else
    invalid_code
  end
end

def authenticate(magic_link)
  if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
    sign_in magic_link
  else
    email_address_mismatch
  end
end

Cookieが指すメールアドレスと、コードの持ち主のメールアドレスが一致して初めてログインです。比較に ActiveSupport::SecurityUtils.secure_compare を使っているのは、実行時間の差から情報が漏れるタイミング攻撃を避けるためですね。

Fizzy のテストには、この挙動を守るための "create with cross-user code" というケースが実際に用意されています。Aさんがメアドを入力した状態でBさんのコードを入力しても弾かれる、というテストです。マジックリンクを自作するなら、このテストは真っ先に書く価値があります。

STEP3:自分のRails 8アプリに実装する

ここからは、読み解いた設計を bin/rails generate authentication の生成物の上に載せていきます。IdentityUser に読み替えた、そのまま動く構成です。

作業前に、これから触るファイルを一覧にしておきます。新しく作るのは6ファイル、既存の生成物に手を入れるのは3ファイルだけです。

ファイル新規/変更やること
db/migrate/..._create_magic_links.rb新規コードと有効期限を持つテーブル
app/models/magic_link.rb新規発行・期限・使い捨て
app/models/magic_link/code.rb新規コード文字列の生成と正規化
app/mailers/magic_link_mailer.rb新規コードをメールで送る
app/controllers/concerns/authentication/via_magic_link.rb新規認証待ち状態をCookieで持ち回る
app/controllers/sessions/magic_links_controller.rb新規コード検証 → ログイン
app/models/user.rb変更send_magic_link を追加
app/controllers/concerns/authentication.rb変更concern を1行 include
app/controllers/sessions_controller.rb変更パスワード照合をコード送信に置き換え

1. MagicLink モデルとマイグレーション

bin/rails generate model MagicLink user:references code:string:uniq expires_at:datetime

生成されたマイグレーションに null: false と有効期限のインデックスを足します。cleanup が期限で絞り込むので、このインデックスは効いてきます。

class CreateMagicLinks < ActiveRecord::Migration[8.0]
  def change
    create_table :magic_links do |t|
      t.references :user, null: false, foreign_key: true
      t.string :code, null: false
      t.datetime :expires_at, null: false

      t.timestamps
    end

    add_index :magic_links, :code, unique: true
    add_index :magic_links, :expires_at
  end
end

モデル本体は Fizzy とほぼ同じです。purpose(サインインかサインアップか)は今回のログイン実装には不要なので落としています。

# app/models/magic_link.rb
class MagicLink < ApplicationRecord
  CODE_LENGTH = 6
  EXPIRATION_TIME = 15.minutes

  belongs_to :user

  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, presence: true, uniqueness: true

  class << self
    # コードを引き当てて、その場で使い捨てる
    def consume(code)
      active.find_by(code: Code.sanitize(code))&.consume
    end

    # 期限切れの掃除(定期実行する)
    def cleanup
      stale.delete_all
    end
  end

  def consume
    destroy
    self
  end

  private
    def generate_code
      self.code ||= loop do
        candidate = 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

2. コード生成モジュール

ここは Fizzy から少しだけ変えます。理由は後述しますが、SecureRandom.base32 は Rails 8.0 / 8.1 には存在しません。どのバージョンでも動くように、文字セットと生成処理を自前で持たせます。

# app/models/magic_link/code.rb
module MagicLink::Code
  # Crockford Base32: 見間違えやすい I / L / O / U を除外した文字セット
  ALPHABET = (("0".."9").to_a + ("A".."Z").to_a - %w[ I L O U ]).freeze

  # ユーザーが打ち間違えやすい文字の読み替え
  SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze

  class << self
    def generate(length)
      Array.new(length) { ALPHABET[SecureRandom.random_number(ALPHABET.size)] }.join
    end

    def sanitize(code)
      if code.present?
        normalize(code)
          .then { |value| substitute(value) }
          .then { |value| remove_invalid_characters(value) }
      end
    end

    private
      def normalize(code)
        code.to_s.upcase
      end

      def substitute(code)
        SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
      end

      def remove_invalid_characters(code)
        code.gsub(/[^#{ALPHABET.join}]/, "")
      end
  end
end

乱数には必ず SecureRandom を使ってください。randArray#sample の既定の乱数は予測可能なので、認証コードには使えません。

3. Userモデルとメーラー

ジェネレータが作った User に、マジックリンクの発行メソッドを足します。has_secure_passwordnormalizes :email_address は生成時点で入っているので、追加するのは3行だけです。

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :magic_links, dependent: :destroy   # 追加

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  # 追加:コードを発行してメールで送る
  def send_magic_link
    magic_links.create!.tap do |magic_link|
      MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
    end
  end
end

メーラーは件名にコードを入れておくのがおすすめです。メールを開かなくても通知だけでコードが読めるので、体感がかなり良くなります(Fizzy も件名にコードを入れています)。

# app/mailers/magic_link_mailer.rb
class MagicLinkMailer < ApplicationMailer
  def sign_in_instructions(magic_link)
    @magic_link = magic_link

    mail to: magic_link.user.email_address,
         subject: "ログイン用コード: #{magic_link.code}"
  end
end
<%# app/views/magic_link_mailer/sign_in_instructions.text.erb %>
ログインページに、次の6文字のコードを入力してください。

  <%= @magic_link.code %>

このコードは <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %> 有効です。
心当たりがない場合は、このメールは破棄してください。

4. Authentication concern に認証待ち状態を足す

生成された Authentication concern は触らず、別ファイルの concern として足して include するのがおすすめです。Rails 本体がジェネレータを更新したときに差分を追いやすくなります。

# app/controllers/concerns/authentication/via_magic_link.rb
module Authentication::ViaMagicLink
  extend ActiveSupport::Concern

  private
    # コードを発行したら、コード入力画面へ送る
    def redirect_to_session_magic_link(magic_link)
      set_pending_authentication_token(magic_link)
      redirect_to session_magic_link_url
    end

    # 存在しないメアド用のダミー(ユーザー列挙対策・後述)
    def redirect_to_fake_session_magic_link(email_address)
      fake_magic_link = MagicLink.new(
        user: User.new(email_address: email_address),
        code: MagicLink::Code.generate(MagicLink::CODE_LENGTH),
        expires_at: MagicLink::EXPIRATION_TIME.from_now
      )

      redirect_to_session_magic_link fake_magic_link
    end

    def set_pending_authentication_token(magic_link)
      cookies[:pending_authentication_token] = {
        value: pending_authentication_token_verifier.generate(
          magic_link.user.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(cookies[:pending_authentication_token])
    end

    def clear_pending_authentication_token
      cookies.delete(:pending_authentication_token)
    end

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

生成済みの Authentication concern には、include とヘルパー登録の2行だけ足します。

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :require_authentication
    helper_method :authenticated?
    helper_method :email_address_pending_authentication   # 追加

    include Authentication::ViaMagicLink                  # 追加
  end

  # 以下、生成されたまま
end

5. SessionsController をパスワードレスに書き換える

ここが一番の変更点です。生成された createUser.authenticate_by(email_address:, password:) でパスワード照合していますが、それをまるごと「コードを発行してメールを送る」に置き換えます。

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create ]
  rate_limit to: 10, within: 3.minutes, only: :create,
    with: -> { redirect_to new_session_url, alert: "しばらく時間をおいてからお試しください。" }

  def new
  end

  def create
    if user = User.find_by(email_address: email_address)
      redirect_to_session_magic_link user.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

そしてコード検証側のコントローラを新しく作ります。MagicLink.consumesecure_compare の二段構えは、Fizzy の作りをそのまま踏襲しています。

# app/controllers/sessions/magic_links_controller.rb
class Sessions::MagicLinksController < ApplicationController
  allow_unauthenticated_access
  rate_limit to: 10, within: 15.minutes, only: :create,
    with: -> { redirect_to session_magic_link_url, alert: "15分ほど時間をおいてからお試しください。" }

  before_action :ensure_email_address_pending_authentication

  def show
  end

  def create
    if magic_link = MagicLink.consume(params.expect(:code))
      authenticate magic_link
    else
      redirect_to session_magic_link_url, alert: "コードが違うようです。もう一度ご確認ください。"
    end
  end

  private
    # メアド入力を飛ばして直接来た人は、入り口へ戻す
    def ensure_email_address_pending_authentication
      unless email_address_pending_authentication.present?
        redirect_to new_session_url, alert: "メールアドレスの入力からやり直してください。"
      end
    end

    def authenticate(magic_link)
      if matching_email_address?(magic_link)
        clear_pending_authentication_token
        start_new_session_for magic_link.user     # ← ジェネレータ生成のメソッドをそのまま使う
        redirect_to after_authentication_url
      else
        clear_pending_authentication_token
        redirect_to new_session_url, alert: "うまくいきませんでした。もう一度お試しください。"
      end
    end

    # 「コードを要求したブラウザ」と「コードの持ち主」が一致するか
    def matching_email_address?(magic_link)
      ActiveSupport::SecurityUtils.secure_compare(
        email_address_pending_authentication.to_s, magic_link.user.email_address
      )
    end
end

6. ルーティングとビュー

ジェネレータが追加した resource :session の中に、コード入力画面をネストさせます。

# config/routes.rb
resource :session do
  scope module: :sessions do
    resource :magic_link, only: %i[ show create ]
  end
end

ログイン画面からはパスワード欄を消して、メールアドレスだけにします。

<%# app/views/sessions/new.html.erb %>
<h1>ログイン</h1>

<%= form_with url: session_path do |form| %>
  <%= form.email_field :email_address, required: true, autofocus: true,
        autocomplete: "username", placeholder: "メールアドレス" %>
  <%= form.submit "ログイン用コードを送る" %>
<% end %>

コード入力画面では autocomplete: "one-time-code" を付けておきましょう。スマホのOSがワンタイムコードだと認識して、キーボードの上に候補として出してくれるので、入力の手間が体感でぐっと減ります(iOSはメール本文の認証コードも対象になります)。

<%# app/views/sessions/magic_links/show.html.erb %>
<h1>メールを確認してください</h1>
<p><strong><%= email_address_pending_authentication %></strong> 宛にコードを送りました。</p>

<%= form_with url: session_magic_link_path, method: :post do |form| %>
  <%= form.text_field :code, required: true, autofocus: true, maxlength: 6,
        autocomplete: "one-time-code", autocapitalize: "off", spellcheck: "false",
        placeholder: "••••••" %>
  <%= form.submit "ログイン" %>
<% end %>

<p>コードは <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %> 有効です。</p>

ここまでで、パスワードなしのログインが一通り動きます。実際に動かすと、こんな見た目になります。

ワンタイムコードが記載された受信メールと、6桁のコード入力画面の完成イメージ
左が届くメール、右がコード入力画面。件名にコードを入れておくと、通知を見るだけで済むので体感が速くなります。

開発中は letter_opener を入れておくと、送信されたメールがブラウザで開くので確認がラクです。

7. 最低限これだけは書きたいテスト

認証は「動いた」だけで安心してはいけない領域です。とはいえ全部テストするのも大変なので、壊れたときに一番怖い2つだけ挙げておきます。Fizzy のテストからも、この2ケースはそのまま拝借できます。

# test/controllers/sessions/magic_links_controller_test.rb
class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
  # ① 他人のコードではログインできない(照合が効いているか)
  test "他人のコードは弾かれる" do
    post session_url, params: { email_address: users(:alice).email_address }
    post session_magic_link_url, params: { code: users(:bob).send_magic_link.code }

    assert_redirected_to new_session_path
    assert_not cookies[:session_id].present?
  end

  # ② 期限切れのコードは消費されない(使い回されないか)
  test "期限切れのコードは使えない" do
    magic_link = users(:alice).send_magic_link
    magic_link.update_column(:expires_at, 1.hour.ago)

    post session_url, params: { email_address: users(:alice).email_address }
    post session_magic_link_url, params: { code: magic_link.code }

    assert_not cookies[:session_id].present?
    assert MagicLink.exists?(magic_link.id), "期限切れのリンクは消費してはいけない"
  end
end

①が落ちるようになったら、それは誰でも他人のアカウントに入れる状態ということです。実装をリファクタリングしたときの安全網として、必ず置いておきましょう。

ハマりどころ4つ

実装できたあとに効いてくる、見落としやすいポイントをまとめます。

1. SecureRandom.base32 は Rails 8.0 / 8.1 には無い

Fizzy のコードをそのままコピーすると、おそらくここで NoMethodError になります。

SecureRandom.base32SecureRandom::BASE32_ALPHABET は ActiveSupport の拡張ですが、Rails 8.0 系・8.1 系のブランチには含まれておらず、main(8.2 系)で追加されたものです。Fizzy 自体が gem "rails", github: "rails/rails", branch: "main" という尖った構成なので、そのまま持ってくると動きません。

先ほど示したように、文字セットを自前で定義してしまえば解決します。「本家のコードを読むときは、そのアプリが乗っている Rails のバージョンも一緒に確認する」——地味ですが、写経で一番ハマるところです。

2. メールアドレスの存在がバレる(ユーザー列挙)

素直に実装すると、こう書きたくなりませんか?

# ❌ 登録の有無が丸わかりになる
def create
  if user = User.find_by(email_address: email_address)
    redirect_to_session_magic_link user.send_magic_link
  else
    redirect_to new_session_url, alert: "そのメールアドレスは登録されていません"
  end
end

これだと、攻撃者はメールアドレスを順番に入力するだけで「どのアドレスがこのサービスに登録済みか」を判別できてしまいます。ユーザー列挙(user enumeration)と呼ばれる情報漏えいです。

Fizzy はこれを redirect_to_fake_session_magic_link という関数名そのままの方法で潰しています。登録が無いメールアドレスでも、その場でダミーの(保存しない)MagicLink オブジェクトを組み立てて、まったく同じコード入力画面に飛ばすのです。

ダミーはDBに保存されないので、当然どんなコードを入れてもログインはできません。それでも攻撃者から見える画面は登録済みの場合と完全に同一——という仕掛けです。権限まわりの情報漏えいについては「権限昇格」による脆弱性でも触れているので、あわせてどうぞ。

3. 期限切れコードが溜まり続ける

MagicLink は期限が切れても自動では消えません。active スコープでヒットしなくなるだけで、レコードは残り続けます。ログイン頻度が高いサービスほど、じわじわ肥大化していきます。

Solid Queue を使っているなら、config/recurring.yml に1行足すだけで掃除できます(Fizzy は4時間ごとに実行しています)。

# config/recurring.yml
production:
  cleanup_magic_links:
    command: "MagicLink.cleanup"
    schedule: every 4 hours

Rails 8 の Solid Queue まわりの設定でつまずいたら、Rails8に入るsolid_queueなどのgemを使った時のdatabase.ymlの書き方も参考にしてください。

4. rate_limit を必ず入れる

6文字のコードは、Crockford Base32(32文字)で 326 = 約10億通りあります。総当たりされる可能性は現実的には低いですが、「コード送信」側の乱用のほうが問題になりやすいです。

他人のメールアドレスを入れて連打されれば、その人の受信箱がコードメールで溢れます。送信元のドメイン評価にも悪影響が出ますよね。Rails 7.2 以降なら rate_limit がコントローラに標準で使えるので、送信側・検証側の両方に必ず設定してください(Fizzy も両方に入れています)。

前半で保留にしていた「リンクを送るか、コードを送るか」をここで整理します。この記事でコード方式を選んだのには理由があります。

観点リンク方式コード方式
ユーザーの手間クリック1回で完了コードを見て入力する(数秒)
別の端末でメールを開いたとき踏んだ端末側でログインしてしまうメールはスマホ、ログインはPCでもよい
メールスキャナ自動でURLを開かれ、消費されることがある自動では消費されない
ブラウザの照合URLだけで完結するぶん実装が甘くなりやすいpending_authentication_token と自然に組み合う
実装コストトークン付きURLを発行するだけコード生成+入力画面が必要

クリック1回で済むリンク方式のほうが一見ラクですが、実務では2つ目と3つ目が地味に効いてきます。

特にメールスキャナに勝手に踏まれる問題は実際に起きます。企業のメールゲートウェイやプレビュー機能は、安全確認のためにURLを自動で開くことがあるからです。使い捨てリンクだとユーザーが開く前にコードが消費されて、ログインできないという状態になります。

法人利用のあるサービスだと、「リンクを押しても無効と言われる」という問い合わせが来てから原因に気づくことになりがちです。

「PCでログインしたいのに、つい手元のスマホでメールを開いてしまった」も日常的に起きます。コード方式なら、どの端末でメールを読んでも、ログインしたいブラウザに打ち込めば済みます。

逆に、コンシューマ向けで入力の手間を1秒でも削りたい場合はリンク方式にも分があります。リンクとコードを両方載せて、ユーザーに選ばせる作りにしているサービスもあるので、迷ったらそこから始めるのも手です。

ログイン状態はどのくらい維持されるのか

前半のデメリットで「メールを開く一手間」を挙げましたが、これはログイン状態を維持すれば、そこまで頻繁には起きません。実際どのくらい維持されるのかを確認しておきましょう。

ここは自分で書いたコードではなく、ジェネレータが生成したコードとブラウザの仕様に委ねている部分なので、意外と見落とされがちです。

Rails側は「実質無期限」

もう一度 start_new_session_for のCookie行を見てみます。

cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }

permanent は Rails では expires: 20.years.from_now のことです(ActionDispatch::Cookies::PermanentCookieJar)。20年です。

そしてジェネレータが生成する Session モデルは belongs_to :user の1行だけで、サーバー側には有効期限がありません。つまり素の状態では、一度ログインしたらほぼ永続的にログインしたままになります。

ブラウザ側には上限がある

とはいえ20年は通りません。ブラウザ側でCookieの寿命に上限がかかります。

ブラウザ上限Railsのセッションクッキーへの影響
Chrome / Edge400日(Chrome 104・2022年8月〜)20年と指定しても400日に切り詰められる。拒否されるわけではない
SafariITPによる7日上限影響しない。7日上限の対象は document.cookie で作ったCookie。Railsのものは httponly: true のサーバー発行なので対象外
Firefox ほか400日(RFC 6265bis で標準化)Chrome と同じ方向に揃いつつある

Safariの「7日でログインが切れる」話はよく聞きますが、あれはJavaScriptで作ったCookieの話です。Rails 8のジェネレータは httponly: true でサーバーから発行しているので、ここに引っかかりません。安心してよいポイントです。

結果として、実用上の目安は「最長400日」と考えておけば大きくは外しません。

ただしRailsは有効期限を自動で延長しない

ここが一番の注意点です。この400日は「ログインした日」から数えます。使い続けても伸びません。

理由はシンプルで、Cookieを発行している start_new_session_forログイン時にしか呼ばれないからです。毎リクエストで走る resume_session は、Cookieを読むだけで書き直していません。

# 生成されたコード:読むだけで、期限は延びない
def resume_session
  Current.session ||= find_session_by_cookie
end

「最終アクセスから400日」にしたいなら、アクセスのたびにCookieを打ち直します。

# app/controllers/concerns/authentication.rb
def resume_session
  Current.session ||= find_session_by_cookie
  refresh_session_cookie if Current.session
  Current.session
end

private
  # アクセスのたびに期限を打ち直して「最終アクセスから」に変える
  def refresh_session_cookie
    cookies.signed.permanent[:session_id] = {
      value: Current.session.id, httponly: true, same_site: :lax
    }
  end

週1回でも使ってもらえるサービスなら、これだけでユーザーはほぼコード入力から解放されます

結局、コード入力が必要になるのはいつか

ここまでを踏まえると、ユーザーが実際にメールを開く必要があるのは次の場面だけです。

  • 初めてログインするとき
  • 新しい端末・別のブラウザを使うとき
  • プライベートウィンドウを使ったとき、Cookieを消したとき
  • 明示的にログアウトしたとき、400日が経過したとき

「ログインのたびに毎回メールを開く」という認識は正確ではありません。体感としては、パスワード認証で「ログイン状態を保持する」にチェックを入れたときと大きく変わらないと考えてよいです。

ただしトレードオフもあります。パスワードが無いぶん、このCookieがログイン状態を保つ唯一の手段です。有効期限を伸ばすほど、端末を他人に触られたときのリスクも一緒に伸びます。

共有PCが想定される業務システムなら、期限を短めにするか、次に説明するパスキーの併用を考えることになります。

パスワードは残すべき?捨てるべき?

最後に判断材料を置いておきます。マジックリンクを入れたからといって、パスワードを消すかどうかは別の話です。

こんなサービスおすすめ
個人開発・社内ツール・利用頻度が低いサービスマジックリンクだけにする。パスワードリセット機能ごと不要になる
共有PC利用やセッションを短く切る業務システムマジックリンク+パスキーを併用。Cookieを長く保てないぶん、メールを開く頻度が上がってしまう
既にパスワードで運用中のサービスまずマジックリンクを追加し、利用率を見てからパスワード廃止を判断する

実際 Fizzy も、マジックリンクを基本にしつつパスキー(WebAuthn)を「もっと速くログインする手段」として案内する作りになっています。コードのメール本文には「パスキーを登録すると、もっと速く安全にログインできます」という追伸まで入っています。

マジックリンクは「全員が確実に使える最低ライン」として置き、頻繁に使う人にはパスキーを勧める。この二段構えは、そのまま真似する価値のある設計だと思います。

迷ったときの一行ルールは、「ログイン状態を長く保てるサービスならマジックリンクだけで十分、セッションを短く切るならパスキーを足す」。まずはマジックリンクを入れて、ユーザーから「メールを開くのが面倒」という声が出てから次を考えれば間に合います。

まとめ

Rails 8 の認証ジェネレータをマジックリンク化する要点を整理します。

  • 差し替えるのは本人確認だけSession モデルや start_new_session_for はそのまま流用できる
  • MagicLink は「6文字・15分・使い捨て」consumedestroy してからレコードを返すのが要
  • コードは見間違えにくい文字セットで発行し、入力は寛容に受けるO0I/L1 の読み替えで失敗が激減する
  • 安全性の肝は署名付きCookie。「コードを要求したブラウザか」を secure_compare で照合してからログインさせる
  • ユーザー列挙対策・rate_limit・期限切れの掃除まで入れて、はじめて本番で使える
  • ログイン状態はCookie次第cookies.signed.permanent は20年でもブラウザ側で最長400日に切り詰められ、しかもRailsは自動で延長しない
  • SecureRandom.base32 は Rails 8.2(main)以降。8.0 / 8.1 では自前で文字セットを定義する

認証は「怖いから gem に任せる」と思われがちな領域ですが、Fizzy のコードを読むと、実際に動いているものが驚くほど素朴なことに気づきます。マジックリンク関連はファイル6つ・合わせて300行ほどしかありません。

ジェネレータで生成したコードが自分のアプリの中にある——というのは、こういう改造ができるということでもあります。まずは開発環境で bin/rails generate authentication を叩いて、パスワード欄を消すところから試してみてはいかがでしょうか。

スポンサーリンク

  • このエントリーをはてなブックマークに追加