Rails8の素の認証機能にOTPを導入してみた
これは Livesense Advent Calendar 2024 DAY 15 の記事です
概要
Rails8 がリリースされたので、新機能を使って二段階認証を実装してみた。
何故やるのか
Rails8 で DHH 謹製の認証機能が追加された。
メールアドレス認証までの機能なので、多段階認証は自分で実装してみて Rails8 のカスタマイズがどこまでできるかの検証をする。
DHH からも基本的な認証機能は提供するが、多段階認証までは対応しないというアナウンスがあるので、DIYの精神で試してみよう。
前提
DIYと言っても二段階認証のロジックからやるのは手間なので、認証のロジック部分はrotp、QR コードの生成はrqrcodeを使うことにする。
作成と動作確認環境は macOS で行う。
やってみたの履歴と解説として進めていく。ハンズオンの為のクックブックではないので、その点はご了承いただきたい。
どこまで作るのか
二段階認証の登録と Sign in 時の認証ができるところまでにする。
実装した際の残件は、これからの課題に記載した。
Rails8 とは
2024 年末にリリースされた最新バージョンの Rails。
ここの youtube チャンネルで、作者の DHH 自身がハンズオンの demo をしているので確認して欲しい。
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 の機能でフィルタリングされている。
ここに記載されている名前を持っているパラメータ(カラム名)は、ログやコンソールに出力される際にフィルタリングされる。
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
ここで作成した画面をルーティングへ登録して、表示できるようにしておく。
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 に表示するリンクを追加する。
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
<!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
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
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"
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 で作成した時の設定のままになっているから、このままでは利用できない。
対応する。
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
・
・
・
<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
を書き換える。
・
・
・
# 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 するために遷移するリンクを画面に埋め込む。
<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 コードの表示を実装する。
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 の文字列の下に表示するようにした。
<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
で認証されていないといけないページを、ログインしてないユーザーは閲覧できないようにしている。
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 のデフォルト設定を追加する。
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 に変更する。
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 も消すことにする
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 の制御を修正する。
先に修正していないソースを見せておく。
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 に移動させることにする。
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 も直す。
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
<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>
<%= 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 ボタンを押してみてもエラー画面になる。
判定ロジックの実装
画面から入力された認証のための値を判定するロジックを実装していく。
class Otp < ApplicationRecord
belongs_to :user
# 判定ロジックを追加
def verify?(code)
totp = ROTP::TOTP.new(otp_secret, issuer: 'OTPTEST')
totp.verify(code)
end
end
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
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 を表示できるようにしておく。
<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 が生成した認証機能の判定ロジックがあるから確認していく。
before_action :require_authentication
before_action :require_authentication
でページに遷移したら、そのページを閲覧してよいかの判断を下している訳だ。
def require_authentication
の中身を見ると、こうなっていた
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 の中で、セッションがあるか否かを判断して、それで認証が通ったかどうか見ている訳だ。
この箇所を改修していく。
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
がなくなってしまったから起きていることだ。
さらに修正を行う。
# 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 がリリースされたようだ。ちょっと気になった内容があったら、また今度、記事にしてみようと思う。
Discussion