🔑

Rails8の素の認証機能にOTPを導入してみた

2024/12/15に公開

これは Livesense Advent Calendar 2024 DAY 15 の記事です

概要

Rails8 がリリースされたので、新機能を使って二段階認証を実装してみた。

何故やるのか

Rails8 で DHH 謹製の認証機能が追加された。
メールアドレス認証までの機能なので、多段階認証は自分で実装してみて Rails8 のカスタマイズがどこまでできるかの検証をする。

DHH からも基本的な認証機能は提供するが、多段階認証までは対応しないというアナウンスがあるので、DIYの精神で試してみよう。
https://github.com/rails/rails/pull/52328

前提

DIYと言っても二段階認証のロジックからやるのは手間なので、認証のロジック部分はrotp、QR コードの生成はrqrcodeを使うことにする。

作成と動作確認環境は macOS で行う。

やってみたの履歴と解説として進めていく。ハンズオンの為のクックブックではないので、その点はご了承いただきたい。

どこまで作るのか

二段階認証の登録と Sign in 時の認証ができるところまでにする。
実装した際の残件は、これからの課題に記載した。

Rails8 とは

2024 年末にリリースされた最新バージョンの Rails。
ここの youtube チャンネルで、作者の DHH 自身がハンズオンの demo をしているので確認して欲しい。

https://www.youtube.com/watch?v=X_Hw9P1iZfQ

Rails8 の認証機能

今までの Rails で認証機能を実装する場合、devise などの gem を使うことが多かったが、Rails8 からはデフォルトの認証機能が追加された。
認証用の基礎的なライブラリは旧バージョンからも提供していたが、Rails8 からはそれらを利用して Sign in/Sign out できるまでのテンプレート(ひな形)が提供されるようになった。
但し、その認証の内容はメールアドレスとパスワードによる確認のみであり、SSO や二段階認証は提供していない。
また、テンプレートからの生成方式であり、devise のような gem での提供ではない。

今回は、この認証機能を使って二段階認証を実装してみる。

二段階認証とは

二段階認証の詳細な説明は記載せず、概略のみとする。

Web サイトや SNS などのログイン時に、ID(メールアドレスなど)やパスワードの入力に加えて、別の認証を行うセキュリティ対策を二段階認証という。

パスワードのみの認証では、パスワードが流出したり、使い回されたりと不正ログインのリスクがある。
その為、二段階認証では、パスワードに加えて別の認証を行うことで、不正アクセスを防ぐことを目的としている。

二段階認証には、いくつかの方式があるが、今回はワンタイムパスワード(OTP)を実装していく。


会社勤めをしていて二段階認証というと、スマートフォンのアプリの google authenticator を使っていると思う。
パスワードを入力した後で、さらにランダムな番号を入力しないとログインできない社内システムやサービスがあると思うが、そういったヤツだ。
(会社によっては、Microsoft Authenticator や Twilio Authy を使ってるケースもあるだろうけど、大体、そんなヤツだと思ってクダサイ)

使用する gem

rotp

ワンタイムパスワードの為に必要な機能を提供する。

device に二段階認証の仕組みを追加できるdevise-two-factor等も、ロジックではこのライブラリを利用している。

rqrcode

QR コードを作成する機能を提供する。

ワンタイムパスワードをスマートフォンのアプリ(google authenticator 等)で読み込ませる場合は、セットアップキーを直接入力するのではなく、QR コードをアプリケーションから読み込ませる方式が定番になっている。
rotp で作成・発行したキーの内容は、このライブラリを利用して QR コードに変換して読み込ませるようにする。

実装

ここからは、実装を行っていく。

rails new

既に私の環境では Rails8 がインストールされているので、新規プロジェクトを作成する。
もし、この記事を参考にして新規プロジェクトを作成する場合は、ruby と rails のバージョンを確認して作業を進めてほしい。

% mkdir otp_rails8
% cd otp_rails8
% rails new .
% bin/dev

新規プロジェクトが作成され、ブラウザから localhost:3000 にアクセスすると Rails のデフォルト画面が表示される。

🌟 いつものステキな初期画面 🌟

ユーザー認証機能の追加

テンプレートの生成

ユーザー認証機能を追加し、db へのテーブル作成も行う。

認証機能の追加にはbin/rails generate authenticationを実行する。
実行した結果、User(ユーザーアカウントを管理する)、Session(Sign in した時のセッションを管理する)の二つのモデルが作成される。
また、Sign in/Sign out する為のコントローラーと、認証の為の具体的なロジックが記載された authentication.rb が作成される。

認証機能のソースコードが作成されたら、今度は生成されたモデル用のテーブルを作成する為にbin/rails db:migrateを実行する。

少し長いが、認証機能を追加すると、どんなファイルが作られるのかも確認できるように、標準出力に表示された内容を全て転記しておく。
個々のファイルに関しての説明は省略する。実装を行う上で説明が必要になったら、各ファイルに言及する。

% bin/rails generate authentication
      invoke  erb
      create    app/views/passwords/new.html.erb
      create    app/views/passwords/edit.html.erb
      create    app/views/sessions/new.html.erb
      create  app/models/session.rb
      create  app/models/user.rb
      create  app/models/current.rb
      create  app/controllers/sessions_controller.rb
      create  app/controllers/concerns/authentication.rb
      create  app/controllers/passwords_controller.rb
      create  app/channels/application_cable/connection.rb
      create  app/mailers/passwords_mailer.rb
      create  app/views/passwords_mailer/reset.html.erb
      create  app/views/passwords_mailer/reset.text.erb
      create  test/mailers/previews/passwords_mailer_preview.rb
      insert  app/controllers/application_controller.rb
       route  resources :passwords, param: :token
       route  resource :session
        gsub  Gemfile
      bundle  install --quiet
    generate  migration CreateUsers email_address:string!:uniq password_digest:string! --force
       rails  generate migration CreateUsers email_address:string!:uniq password_digest:string! --force
      invoke  active_record
      create    db/migrate/20241202063702_create_users.rb
    generate  migration CreateSessions user:references ip_address:string user_agent:string --force
       rails  generate migration CreateSessions user:references ip_address:string user_agent:string --force
      invoke  active_record
      create    db/migrate/20241202063703_create_sessions.rb
      invoke  test_unit
      create    test/fixtures/users.yml
      create    test/models/user_test.rb
% bin/rails db:migrate
== 20241128062014 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0013s
-- add_index(:users, :email_address, {:unique=>true})
   -> 0.0004s
== 20241128062014 CreateUsers: migrated (0.0017s) =============================

== 20241128062015 CreateSessions: migrating ===================================
-- create_table(:sessions)
   -> 0.0012s
== 20241128062015 CreateSessions: migrated (0.0012s) ==========================

ユーザーの作成

元々の認証機能では Sign in/Sign out の機能は提供されるが、新規登録(Sign up)は提供されない。
今回は主目的ではないので、新規登録の為の機能や画面は作成せずに、rails consoleでユーザーを作成する。

% bin/rails c
Loading development environment (Rails 8.0.0)
otp-rails8(dev)> User.create(email_address: "example@example.com", password: "password")
  TRANSACTION (0.1ms)  BEGIN immediate TRANSACTION /*application='OtpRails8'*/
  User Create (1.9ms)  INSERT INTO "users" ("email_address", "password_digest", "created_at", "updated_at") VALUES ('example@example.com', '$2a$12$bAP90G8b/rn0/4fjVr0LD.WoSjeMq5g0GIhjHuqLyjxjl4E5lvYXK', '2024-11-28 06:21:17.333094', '2024-11-28 06:21:17.333094') RETURNING "id" /*application='OtpRails8'*/
  TRANSACTION (0.2ms)  COMMIT TRANSACTION /*application='OtpRails8'*/
=>
#<User:0x0000000111cd8310
 id: 1,
 email_address: "[FILTERED]",
 password_digest: "[FILTERED]",
 created_at: "2024-11-28 06:21:17.333094000 +0000",
 updated_at: "2024-11-28 06:21:17.333094000 +0000">
otp-rails8(dev)> exit

[FILTERED]となっている箇所は、rails の機能でフィルタリングされている。
ここに記載されている名前を持っているパラメータ(カラム名)は、ログやコンソールに出力される際にフィルタリングされる。

config/initalizers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
:pass, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]

ダミー画面の作成

ログインした時に遷移する画面を作成する。

% rails g controller home index dashboard

      create  app/controllers/home_controller.rb
       route  get "home/index"
              get "home/dashboard"
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      create    app/views/home/dashboard.html.erb
      invoke  test_unit
      create    test/controllers/home_controller_test.rb
      invoke  helper
      create    app/helpers/home_helper.rb
      invoke    test_unit

ここで作成した画面をルーティングへ登録して、表示できるようにしておく。

config/routes.rb
Rails.application.routes.draw do
  # この行は、rails gで作成された
  get "home/index"
  get "home/dashboard"
  ・
  ・
  ・
  # Defines the root path route ("/")
  # root "posts#index"
  # この行を追加
  root "home#index"
end

ヘルパーの作成と layout への反映

Sign in しているか確認するヘルパーを作成して、layout に表示するリンクを追加する。

app/helpers/application_helper.rb
module ApplicationHelper
    #ユーザーがサインインしているかどうかを返す
    def user_Signed_in?
       Session.find_by(id: cookies.Signed[:session_id])&.user&.present?
    end
end

layouts/application.html.erb に、<% if user_Signed_in? %>から先を追加する

app/views/layouts/application.html.erb
app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    ・
    ・
    ・
  <body>
  <% if user_Signed_in? %>
    <%= link_to 'Sign Out', session_path, data: { "turbo-method": :delete }, method: :delete %>
  <% else %>
    <%= link_to 'Sign In', new_session_path %>
  <% end %>

    <%= yield %>
  </body>
</html>

ここまでできたら、http://localhost:3000/session/new にアクセスして、ログイン画面が表示されることを確認する。

rails console で作ったユーザーでログインして、ログイン後の画面が表示されることを確認する。

ここまでが通常の認証機能の追加とログインまでの実装となる。

二段階認証の追加

二段階認証の実装を行っていく。
まずは gem のインストールから行う。

% bundle add rotp rqrcode

rotp の動作確認

otp の動作確認をしてみる。

% bin/rails c
otp-rails8(dev)> totp = ROTP::TOTP.new("base32secret3232", issuer: "My Service")
=> #<ROTP::TOTP:0x000000011147a790 @digest="sha1", @digits=6, @interval=30, @issuer="My Service", @name=nil, @provisioning_params={}, @secret="base32secret3232">
otp-rails8(dev)> totp.now # 今のワンタイムパスワードの値を取得。
=> "557486"
otp-rails8(dev)> totp.verify("557486") # 大急ぎで入力する。
=> 1732776810
otp-rails8(dev)> sleep 30 # 30秒待つ
=> 30
otp-rails8(dev)> totp.verify("557486") # 30秒後に入力されると、既に古いワンタイムパスワードとなり、失敗してnilがかえってくる。
=> nil

きちんと使えることが確認できた。

QR コードの表示

QR コードの生成処理の動作確認を行う。

otp-rails8(dev)> uri = totp.provisioning_uri('localhost@example.com')
=> "otpauth://totp/My%20Service:localhost%40example.com?secret=base32secret3232&issuer=My%20Service"
otp-rails8(dev)> qrcode = RQRCode::QRCode.new(uri)
=> #<RQRCode::QRCode:0x0000000106d04718 @qrcode=QRCodeCore: @data='#<RQRCodeCore::QRSegment:0x0000000106d041c8>', @error_correct_level=2, @version=9, @module_count=53>
otp-rails8(dev)* png = qrcode.as_png(
otp-rails8(dev)*   bit_depth: 1,
otp-rails8(dev)*   border_modules: 4,
otp-rails8(dev)*   color_mode: ChunkyPNG::COLOR_GRAYSCALE,
otp-rails8(dev)*   color: "black",
otp-rails8(dev)*   file: nil,
otp-rails8(dev)*   fill: "white",
otp-rails8(dev)*   module_px_size: 6,
otp-rails8(dev)*   resize_exactly_to: false,
otp-rails8(dev)*   resize_gte_to: false,
otp-rails8(dev)*   size: 120
otp-rails8(dev)> )
=>
<ChunkyPNG::Image 120x120 [
...
otp-rails8(dev)> IO.binwrite("./test-qrcode.png", png.to_s)
=> 559

./test-qrcode.pngというファイルが作成されているはずなので、確認してみる。

open test-qrcode.png
google Authenticator への登録


QR コードを、スマートフォンにインストールした google Authenticator から登録する。

+ボタンを押して QR コードを読み込む。

正常に読み込まれて、My Service という名前で登録された。

記載されている数字が正しくワンタイムパスワードとして利用できるか確認する。

otp-rails8(dev)> totp.verify("382686") # 大急ぎで入力する。
=> 1732777380
otp-rails8(dev)> sleep 30 # 30秒待つ
=> 30
otp-rails8(dev)> totp.verify("382686") # 30秒後に入力されると、既に古いワンタイムパスワードとなり、失敗してnilがかえってくる。
=> nil

ワンタイムパスワードの生成と検証ができた。

二段階認証の登録機能の実装

動作確認・検証した内容を元に実装を行う。

モデルの追加、User との紐付け

% rails g scaffold otp user:references otp_secret:string
      invoke  active_record
      create    db/migrate/20241130085947_create_otps.rb
      create    app/models/otp.rb
      invoke    test_unit
      create      test/models/otp_test.rb
      create      test/fixtures/otps.yml
      invoke  resource_route
       route    resources :otps
      invoke  scaffold_controller
      create    app/controllers/otps_controller.rb
      invoke    erb
      create      app/views/otps
      create      app/views/otps/index.html.erb
      create      app/views/otps/edit.html.erb
      create      app/views/otps/show.html.erb
      create      app/views/otps/new.html.erb
      create      app/views/otps/_form.html.erb
      create      app/views/otps/_otp.html.erb
      invoke    resource_route
      invoke    test_unit
      create      test/controllers/otps_controller_test.rb
      create      test/system/otps_test.rb
      invoke    helper
      create      app/helpers/otps_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/otps/index.json.jbuilder
      create      app/views/otps/show.json.jbuilder
      create      app/views/otps/_otp.json.jbuilder
db/migrate/20241130085947_create_otps.rb
class CreateOtps < ActiveRecord::Migration[8.0]
  def change
    create_table :otps do |t|
      t.references :user, null: false, foreign_key: true, index: { unique: true }
      t.string :otp_secret

      t.timestamps
    end
  end
end
config/routes.rb
Rails.application.routes.draw do
  resource :otp, only: [:show, :create ]
  get "home/index"
  get "home/dashboard"
  resource :session
  resources :passwords, param: :token
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
  # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
  # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker

  # Defines the root path route ("/")
  # root "posts#index"
  get "dashboard", to: "home#dashboard"
  root "home#index"
app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }
  has_one :otp
end
rails db:migrate
rails console での動作確認

実際に User と Otp モデルの挙動を console で試してみる。

% bin/rails c
otp-rails8(dev)> otp = User.first.build_otp(otp_secret: ROTP::Base32.random) # 始めに作ったユーザーに、otpの設定を追加する。ROTP::Base32.randomはランダムな文字列を生成する、ROTPの提供するメソッド
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='OtpRails8'*/
  Otp Load (0.1ms)  SELECT "otps".* FROM "otps" WHERE "otps"."user_id" = 1 LIMIT 1 /*application='OtpRails8'*/
=> #<Otp:0x00000001154f0b58 id: nil, user_id: 1, otp_secret: "[FILTERED]", created_at: nil, updated_at: nil>
otp-rails8(dev)> otp.save
  TRANSACTION (0.2ms)  BEGIN immediate TRANSACTION /*application='OtpRails8'*/
  Otp Create (0.8ms)  INSERT INTO "otps" ("user_id", "otp_secret", "created_at", "updated_at") VALUES (1, 'PFH4GDCHJU3JMBFZMMZODNRWZIQDYA62', '2024-11-30 10:52:30.871890', '2024-11-30 10:52:30.871890') RETURNING "id" /*application='OtpRails8'*/
  TRANSACTION (1.7ms)  COMMIT TRANSACTION /*application='OtpRails8'*/
=> true
otp-rails8(dev)> otp
=> #<Otp:0x00000001154f0b58 id: 5, user_id: 1, otp_secret: "[FILTERED]", created_at: "2024-11-30 10:52:30.871890000 +0000", updated_at: "2024-11-30 10:52:30.871890000 +0000">
otp-rails8(dev)> User.first.otp
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='OtpRails8'*/
  Otp Load (0.0ms)  SELECT "otps".* FROM "otps" WHERE "otps"."user_id" = 1 LIMIT 1 /*application='OtpRails8'*/
=> #<Otp:0x00000001154f4758 id: 5, user_id: 1, otp_secret: "[FILTERED]", created_at: "2024-11-30 10:52:30.871890000 +0000", updated_at: "2024-11-30 10:52:30.871890000 +0000">
otp-rails8(dev)> otp.destroy # 実際の画面で、再度、追加する予定なので、削除しておく。
  TRANSACTION (0.1ms)  BEGIN immediate TRANSACTION /*application='OtpRails8'*/
  Otp Destroy (0.6ms)  DELETE FROM "otps" WHERE "otps"."id" = 5 /*application='OtpRails8'*/
  TRANSACTION (0.2ms)  COMMIT TRANSACTION /*application='OtpRails8'*/
=> #<Otp:0x00000001154f0b58 id: 5, user_id: 1, otp_secret: "[FILTERED]", created_at: "2024-11-30 10:52:30.871890000 +0000", updated_at: "2024-11-30 10:52:30.871890000 +0000">
otp-rails8(dev)> User.first.otp # 削除されていることを確認する。nilがかえってきたら、成功だ
  User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='OtpRails8'*/
  Otp Load (0.0ms)  SELECT "otps".* FROM "otps" WHERE "otps"."user_id" = 1 LIMIT 1 /*application='OtpRails8'*/
=> nil
otp-rails8(dev)>

ここまでのまとめと、実装時の留意点

ここまでで、ユーザーに Otp モデルを紐付けることができた。
また、既に ROTP で、実際にワンタイムパスワードの生成と検証ができることも確認できた。

otp_secret に保存する値は、ランダムに作成した方がいいが、今回はROTPが提供してくれている ROTP::Base32.random を使っていく。

totp = ROTP::TOTP.new(otp_secret に保存する値, issuer:サービス名)
uri = totp.provisioning_uri(アカウントのメールアドレス)

上のように設定したら、QR コードを生成することができる。
issure は"OTPTEST"としよう。

Controller の実装

登録用の otp_secret の Q Rコードを生成する画面と、認証する画面を作っていく。
実はこの時点で、otp のページは、Sign up しないと表示できないようになっている。

チョット試してみよう。一度、Sign out をクリックした上で、localhost:3000/otpの url をアドレスバーに入力する。
自動的にログイン画面へ戻された。

今度は、ログインしてからlocalhost:3000/otp

こんなエラーになる。

パラメータ等を scaffold で作成した時の設定のままになっているから、このままでは利用できない。
対応する。

app/controllers/otps_controller.rb
class OtpsController < ApplicationController
  before_action :set_otp, only: %i[ show edit update destroy ]
  ・
  ・
  ・
  private
    # Use callbacks to share common setup or constraints between actions.
    def set_otp
      # @otp = Otp.find(params.expect(:id))
      @otp = Current.user.otp
    end
  ・
  ・
  ・
app/views/otps/show.html.erb
<p style="color: green"><%= notice %></p>
 <%# otpが設定されていたら表示、されていなかったら表示しない %>
<% if @otp %>
<%= render @otp %>
<% else %>
OTP Not found.
<% end %>

<div>
  <%# ここの三行は コメントアウトする %>

  <%#= link_to "Edit this otp", edit_otp_path(@otp) %> |
  <%#= link_to "Back to otps", otps_path %>

  <%#= button_to "Destroy this otp", @otp, method: :delete %>
</div>


実際に生成して、QR コードを表示するとこを実装していく。

def create を書き換える。

app/controllers/otps_controller.rb
  ・
  ・
  ・
  # POST /otps or /otps.json
  def create
    # @otp = Otp.new(otp_params)
    @otp = Current.user.build_otp(otp_secret: ROTP::Base32.random)

    respond_to do |format|
      if @otp.save
        format.html { redirect_to @otp, notice: "Otp was successfully created." }
        format.json { render :show, status: :created, location: @otp }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @otp.errors, status: :unprocessable_entity }
      end
    end
  end
  ・
  ・
  ・

そして、create するために遷移するリンクを画面に埋め込む。

app/views/otps/show.html.erb
<p style="color: green"><%= notice %></p>
 <%# otpが設定されていたら表示、されていなかったら表示しない %>
<% if @otp %>
  <%= render @otp %>
<% else %>
  OTP Not found.
  </br>
  <%= link_to 'OTP Create.',otp_path(params: {}), data: { "turbo-method": :post }, class: "no-underline" %>
<% end %>

<div>
  <%# ここの三行は コメントアウトする %>

  <%#= link_to "Edit this otp", edit_otp_path(@otp) %> |
  <%#= link_to "Back to otps", otps_path %>

  <%#= button_to "Destroy this otp", @otp, method: :delete %>
</div>

ログインしてから localhost:3000/otp へ。

その後、OTP Create.のリンクをクリック。

こんな画面が出る。Otp secret が表示されているから、この値を入力したら Google Authenticator に登録できる。
この値を手動で google の認証アプリに登録するのは手間なので、QR コードを表示するように修正していく。

QR コードの表示

QR コードの表示を実装する。

app/controllers/otps_controller.rb
class OtpsController < ApplicationController
  before_action :set_otp, only: %i[ show edit update destroy ]
  ・
  ・
  ・
  # GET /otps/1 or /otps/1.json
  def show
    if Current.user.otp
      totp = ROTP::TOTP.new(Current.user.otp.otp_secret, issuer: 'OTPTEST')

      uri = totp.provisioning_uri(Current.user.email_address)
      qrcode = RQRCode::QRCode.new(uri)
      @png = qrcode.as_png(
          bit_depth: 1,
          border_modules: 4,
          color_mode: ChunkyPNG::COLOR_GRAYSCALE,
          color: "black",
          file: nil,
          fill: "white",
          module_px_size: 6,
          resize_exactly_to: false,
          resize_gte_to: false,
          size: 120
        )
    end
  end

Otp secret の文字列の下に表示するようにした。

app/views/otps/_otp.html.erb
<div id="<%= dom_id otp %>">
  <p>
    <strong>User:</strong>
    <%= otp.user_id %>
  </p>

  <p>
    <strong>Otp secret:</strong>
    <%= otp.otp_secret %>
  </p>
  <p>
    <strong>Otp qr:</strong>
    </br>
    <%= image_tag "data:image/png;base64,#{Base64.strict_encode64(@png.to_s)}", alt: "QR Code" %>
  </p>

</div>

QR コードが表示されるようになった。

実際にアプリから読み込んでみて、登録できた。

ここまででOTPの登録が完成した。

今度は、実際に認証する箇所を作っていこう。

二段階認証の認証の実装

標準の認証機能の確認

標準の認証機能では、セッションという model で管理している。

認証の実際のロジックはauthentication.rbに記載されているので確認する。
before_action :require_authenticationで認証されていないといけないページを、ログインしてないユーザーは閲覧できないようにしている。

app/controllers/concerns/authentication.rb
def require_authentication
  resume_session || request_authentication
end


def resume_session
  Current.session ||= find_session_by_cookie
end

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

今回はこの session のモデルに otp_session というモデルを関連付けて、otp のセッション管理を行う。

otp_session を追加

otp 認証の設定ががあるユーザのみ、普通のセッションとは別に otp 向けのセッションを管理する仕組みを作る。
otp_session を scafforld で作成する。

 % bin/rails g scaffold otp_session session:references verify:boolean
      invoke  active_record
      create    db/migrate/20241130214012_create_otp_sessions.rb
      create    app/models/otp_session.rb
      invoke    test_unit
      create      test/models/otp_session_test.rb
      create      test/fixtures/otp_sessions.yml
      invoke  resource_route
       route    resources :otp_sessions
      invoke  scaffold_controller
      create    app/controllers/otp_sessions_controller.rb
      invoke    erb
      create      app/views/otp_sessions
      create      app/views/otp_sessions/index.html.erb
      create      app/views/otp_sessions/edit.html.erb
      create      app/views/otp_sessions/show.html.erb
      create      app/views/otp_sessions/new.html.erb
      create      app/views/otp_sessions/_form.html.erb
      create      app/views/otp_sessions/_otp_session.html.erb
      invoke    resource_route
      invoke    test_unit
      create      test/controllers/otp_sessions_controller_test.rb
      create      test/system/otp_sessions_test.rb
      invoke    helper
      create      app/helpers/otp_sessions_helper.rb
      invoke      test_unit
      invoke    jbuilder
      create      app/views/otp_sessions/index.json.jbuilder
      create      app/views/otp_sessions/show.json.jbuilder
      create      app/views/otp_sessions/_otp_session.json.jbuilder

scaffold で作成した内容を修正し、テーブルの作成時の unique や verify のデフォルト設定を追加する。

db/migrate/20241130214012_create_otp_sessions.rb
class CreateOtpSessions < ActiveRecord::Migration[8.0]
  def change
    create_table :otp_sessions do |t|
      t.references :session, null: false, foreign_key: true, index: { unique: true }
      t.boolean :verify, default: false

      t.timestamps
    end
  end
end

otp_session の resources を resource に変更する。

config/routes.rb
Rails.application.routes.draw do
  # resources :otp_sessions
  resource :otp_sessions


  resource :otp, only: [:show, :create ]
  get "home/index"
  get "home/dashboard"
  resource :session


  resources :passwords, param: :token
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
  # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
  # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker

  # Defines the root path route ("/")
  # root "posts#index"
  get "dashboard", to: "home#dashboard"
  root "home#index"
end

この状態でマイグレーションを実行してテーブルを作成する。

% bin/rails db:migrate
== 20241130214012 CreateOtpSessions: migrating ================================
-- create_table(:otp_sessions)
   -> 0.0019s
== 20241130214012 CreateOtpSessions: migrated (0.0020s) =======================
rails console での動作確認

ちょっと、rails console で確認してみよう。
現状のユーザーのセッション情報はどうなってるだろうか。

 % rails c
Loading development environment (Rails 8.0.0)
otp-rails8(dev)> Session.last
  Session Load (0.0ms)  SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT 1 /*application='OtpRails8'*/
=>
#<Session:0x0000000107398980
 id: 6,
 user_id: 1,
 ip_address: "::1",
 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Ap...",
 created_at: "2024-11-30 11:58:13.907241000 +0000",
 updated_at: "2024-11-30 11:58:13.907241000 +0000">
otp-rails8(dev)> Session.last.otp_session # まだ、otp_session は紐付いていない
  Session Load (0.3ms)  SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT 1 /*application='OtpRails8'*/
  OtpSession Load (0.2ms)  SELECT "otp_sessions".* FROM "otp_sessions" WHERE "otp_sessions"."session_id" = 6 LIMIT 1 /*application='OtpRails8'*/
=> nil

Model の実装

次に model の生成時に、該当のユーザーが otp を持っていたら、紐付ける用にする。あと、セッションを削除したら、一緒に otp_session も消すことにする

app/models/session.rb
class Session < ApplicationRecord
  belongs_to :user
  has_one :otp_session, dependent: :destroy # has_oneとdependentの設定を追加
  after_save :create_otp_session # saveした差異に、otp_sessionを作成するメソッドを追加
  private
  def create_otp_session
    if user.otp
      otp_session || create_otp_session!
    end
  end
end

この状態で、一度、localhost:3000 から Sign out/Sign in し、コンソールから確認してみる。

 % rails c
Loading development environment (Rails 8.0.0)
otp-rails8(dev)> Session.last
  Session Load (0.4ms)  SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT 1 /*application='OtpRails8'*/
=>
#<Session:0x000000010c67ae28
 id: 7,
 user_id: 1,
 ip_address: "::1",
 user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Ap...",
 created_at: "2024-11-30 21:51:14.374097000 +0000",
 updated_at: "2024-11-30 21:51:14.374097000 +0000">
otp-rails8(dev)> Session.last.otp_session
  Session Load (0.3ms)  SELECT "sessions".* FROM "sessions" ORDER BY "sessions"."id" DESC LIMIT 1 /*application='OtpRails8'*/
  OtpSession Load (0.1ms)  SELECT "otp_sessions".* FROM "otp_sessions" WHERE "otp_sessions"."session_id" = 7 LIMIT 1 /*application='OtpRails8'*/
=>
#<OtpSession:0x000000010c055a98
 id: 1,
 session_id: 7,
 verify: false,
 created_at: "2024-11-30 21:51:14.381437000 +0000",
 updated_at: "2024-11-30 21:51:14.381437000 +0000">
otp-rails8(dev)>

otp の設定をしたユーザーには、ちゃんと otp_session が紐付いたようだ。

Controller の実装

今度は controller の制御を修正する。
先に修正していないソースを見せておく。

app/controllers/session_controller.rb
class SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ new create otp_auth ]
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }

  def new
  end

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

start_new_session_for というので、開始しているらしい。
otp を持っているユーザーだったら、after_authentication_url ではなくって、otp_session に移動させることにする。

app/controllers/session_controller.rb
class SessionsController < ApplicationController
  ・
  ・
  ・
  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      if user.otp
        redirect_to edit_otp_sessions_url
      else
        redirect_to after_authentication_url
      end
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end

とりあえず動くように otp_sessions_controller.rb と view も直す。

app/controllers/otp_sessions_controller.rb
class OtpSessionsController < ApplicationController
  before_action :set_otp_session, only: %i[ show edit update destroy ]
  ・
  ・
  ・
  private
    # Use callbacks to share common setup or constraints between actions.
    def set_otp_session
      #@otp_session = OtpSession.find(params.expect(:id))
      @otp_session = Current.session
    end

    # Only allow a list of trusted parameters through.
    def otp_session_params
      #params.expect(otp_session: [ :session_id, :verify ])
      params.expect(:verify_code)
    end
end
app/views/otp_sessions/show.html.erb
<p style="color: green"><%= notice %></p>

<%= render @otp_session %>

<div>
  <%# ここをコメントアウトする %>
  <%# = link_to "Edit this otp session", edit_otp_session_path(@otp_session) %> |
  <%# = link_to "Back to otp sessions", otp_sessions_path %>

  <%# = button_to "Destroy this otp session", @otp_session, method: :delete %>
</div>
app/views/otp_sessions/_form.html.erb
<%= form_with url:otp_sessions_path do |form| %>
  <%  if otp_session.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(otp_session.errors.count, "error") %> prohibited this otp_session from being saved:</h2>

      <ul>
        <% otp_session.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <%  end %>
      </ul>
    </div>
  <%  end %>

  <div>
    <%= form.label :session_id, style: "display: block" %>
    <%= form.text_field :session_id %>
  </div>

  <div>
    <%= form.label :verify, style: "display: block" %>
    <%= form.checkbox :verify %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>
<% content_for :title, "Editing otp session" %>

<h1>Editing otp session</h1>

<%= render "form", otp_session: @otp_session %>

<br>

<div>
  <%# ここをコメントアウトする %>
  <%# = link_to "Show this otp session", @otp_session %> |
  <%# = link_to "Back to otp sessions", otp_sessions_path %>
</div>

そして再び、otp を持っているユーザーでログアウト、ログインをすると、http://localhost:3000/otp_sessions/editに遷移する。


今度はこの画面で verify のコードを入力できるようにしてみよう。

_form から、id とかを削除して verify_code の入力欄を作る。データの更新なので、patch を明示的に指定する。

<%= form_with url:otp_sessions_path, method: :patch  do |form| %>
  <%  if otp_session.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(otp_session.errors.count, "error") %> prohibited this otp_session from being saved:</h2>

      <ul>
        <% otp_session.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <%  end %>
      </ul>
    </div>
  <%  end %>

  <div>
    <%= form.label :verify_code, style: "display: block" %>
    <%= form.text_field :verify_code %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

再度、Sign out してから Sign in するとこうなった。

今の時点で Verify code を入力して save ボタンを押してみてもエラー画面になる。

判定ロジックの実装

画面から入力された認証のための値を判定するロジックを実装していく。

/app/model/otp.rb
class Otp < ApplicationRecord
  belongs_to :user
  # 判定ロジックを追加
  def verify?(code)
    totp = ROTP::TOTP.new(otp_secret, issuer: 'OTPTEST')
    totp.verify(code)
  end
end

/app/model/session.rb
class Session < ApplicationRecord
  belongs_to :user
  has_one :otp_session, dependent: :destroy
  after_save :create_otp_session
  # 上で追加した verify? メソッドを使って、otp_session の確認を行う
  def otp_verify(code)
    if user.otp.verify?(code)
      otp_session.verify = true
      true
    else
      errors.add(:otp_session, 'is invalid')
      false
    end
  end
end
/app/controllers/otp_sessions_controller.rb
class OtpSessionsController < ApplicationController
  before_action :set_otp_session, only: %i[ show edit update destroy ]
  ・
  ・
  ・
  private
    # Use callbacks to share common setup or constraints between actions.
    def set_otp_session
      #@otp_session = OtpSession.find(params.expect(:id))
      @otp_session = Current.session
    end

    # Only allow a list of trusted parameters through.
    def otp_session_params
      #params.expect(otp_session: [ :session_id, :verify ])
      params.expect(:verify_code)
    end
end

結果が分かるように、home に戻った時にも notice を表示できるようにしておく。

app/views/home/index.html.erb
<h1>Home#index</h1>
<p style="color: green"><%= notice %></p>
<p>Find me in app/views/home/index.html.erb</p>
動作確認

認証のロジックを確認する為に、Sign out してから Sign in をやり直してみる。
適当な誤っている内容を入れると、エラー画面になった。

スマートフォンに表示された正しい値を入れると、成功した。

認証機能への追加修正

これで OTP の認証が通って一件落着と言いたいが、もう少し実装を行っていく。

今の構造だとメールアドレスとパスワードの認証が通ってしまったら、もう OTP による認証をしなくても、他のページが閲覧できてしまう

もう一度、ログアウトして、メールアドレスによる認証が終わって、http://localhost:3000/otp_sessions/editへ移動した後で localhost:3000/dashboard に遷移してみる。

これは、閲覧してはいけないページの制御が、メールアドレスとパスワードによる認証が終わったら閲覧できるようになっている為だ。

今度は、これを対応する。

Controller の修正

authentication.rb に Rails が生成した認証機能の判定ロジックがあるから確認していく。

app/controllers/concerns/authentication.rb
before_action :require_authentication

before_action :require_authentication でページに遷移したら、そのページを閲覧してよいかの判断を下している訳だ。

def require_authentication の中身を見ると、こうなっていた

app/controllers/concerns/authentication.rb
included do
  before_action :require_authentication
end


private

  def require_authentication
    resume_session || request_authentication
  end


  def resume_session
    Current.session ||= find_session_by_cookie
  end

  def find_session_by_cookie
    Session.find_by(id: cookies.signed[:session_id])
  end


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

require_authentication の中で、セッションがあるか否かを判断して、それで認証が通ったかどうか見ている訳だ。
この箇所を改修していく。

app/controllers/concerns/authentication.rb
  def find_session_by_cookie
    session = Session.find_by(id: cookies.signed[:session_id])
    if session&.otp_session
      if session.otp_session.verify
        session
      end
    else
      session
    end
  end

もう一度、Sign out して Sign in してみる。
今度は otp_session へ遷移しなくなってしまったので、Sign in しないでも閲覧できるようにする。

class OtpSessionsController < ApplicationController
  before_action :set_otp_session, only: %i[ show edit update destroy ]
  # この設定を入れておくと、該当の画面はSign inしていなくても閲覧できる。詳細は、authentication.rbのメソッドを参照
  allow_unauthenticated_access

セッションの管理がおかしくなっているから、通常通りログアウトできないが、気にせずに session/new でメールアドレスとパスワードを打つ。

そうすると戻ったものの、エラーとなった。

これは、authentication.rb の機能で Current.session に乗っている値を使って仕組みを動かしていたが、今の修正で Currrent.session がなくなってしまったから起きていることだ。

さらに修正を行う。

app/controllers/otp_sessions_controller.rb
  # PATCH/PUT /otp_sessions/1 or /otp_sessions/1.json
  def update
    respond_to do |format|
#      if Current.session.otp_verify(otp_session_params)
      if @otp_session.otp_verify(otp_session_params)
#        format.html { redirect_to @otp_session, notice: "Otp session was successfully updated." }
        format.html { redirect_to after_authentication_url, notice: "Otp session was successfully updated." }
        format.json { render :show, status: :ok, location: @otp_session }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @otp_session.errors, status: :unprocessable_entity }
      end
    end
  end
  ・
  ・
  ・
  def set_otp_session
    #@otp_session = OtpSession.find(params.expect(:id))
    # @otp_session = Current.session
    @otp_session = Session.find_by(id: cookies.signed[:session_id])
  end

再びhttp://localhost:3000/session/newへ戻って、はじめからやり直して otp_session での認証までを行い、Sign in ができた事を確認した。

これからの課題

一通りの機能は otp 向けに追加したが、まだまだ機能は足りないし、要修正点もある。
ざっとあげると、以下のようなものがある。

  • otp::otp_secret が暗号化されてない。Active Record Encryption 等で暗号化してDBへ保存しておくべきだろう
  • OTP Create.の際に文字列まで表示してしまっている。削除して、QR コードのみの表示にすべき
  • otp を設定したユーザーで password まで入力してから dashboard に移動すると、session/new に遷移されてしまう。この場合は、本来は otp_session へ遷移する方が良いだろう
  • ヘルパーで作成した Sign out/Sign in のリンクは、otp 対応されてない
  • リカバリーキーの発行機能の追加
  • otp_session のページへは、今のままだとメールアドレスとパスワードの認証が終わってなくても見れてしまう。新たに Authentication の def allow_unauthenticated_access に類似したメールアドレスとパスワードの認証まで済んだユーザーには閲覧できる様なヘルパーを作成して、修正する
  • scaffold で作成した機能の余分な箇所の削除
  • scaffold で作成したので、otp_session のコントローラー名が複数形のままになっている。session にそろえて、単数形に直すべき(チョット格好悪いね)

課題事項に関しては記事の本旨からは外れるので、今回は省略する。

結論、まとめ

ここまでの実装で Rails 側が生成した認証機能を確認する時間も含めて二、三時間程度で対応できた。残件も含めたとしても、数日もかからないだろう。

今回はできるだけ rails way でやりたかったので、特別なメソッドを生やしたりせず、scalfold で作成したものを使ってみた(Controller などに def auth_generator みたいなのを作らないようにした)。
実務では悪名高い after_save も使ってみた。用法用量を守って、Rails を巨大な DSL として使うなら、自己判断で使っていけばいいんじゃないかと思う。

device とか gem を使って全体の構成を変えたくない(model の中に知らないロジックが入ってくるのがイヤとか)方針だったら、rails8 の標準の認証機能はとっつきやすい感触だった。
rails のバージョンをあげる度に gem のバージョンアップを気にせずに、自分自身で修正できるので、気楽に対応できるのが良い。

rails のバージョンアップ対応を行う際の問題点として gem 側の対応が完了するまで待ったり、モンキーパッチを当てながらダマシダマシ対応するケースが往々にしてみられるが、バージョンアップ対応のイニシアチブを、もう少し自分でコントロールしたいなら標準の認証機能を利用していくのも一つの手だろう。

ヲマケ

どうやら、Rails 8.0.1 がリリースされたようだ。ちょっと気になった内容があったら、また今度、記事にしてみようと思う。

https://rubyonrails.org/2024/12/13/Rails-Version-8-0-1-has-been-released

Livesense Engineers

Discussion