4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Code PolarisAdvent Calendar 2024

Day 3

Rails8の認証機能試してみた!

Last updated at Posted at 2024-12-02

この記事は『Code Polaris Advent Calendar 2024』 の3日目の記事です。

この記事について

Rails8が11/8に正式リリースとなりました🎉
https://www.publickey1.jp/blog/24/ruby_on_rails_8sqlitedb6.html

なんとこの記事を書いている土日に珍しく夫が仕事休んでくれて子どもを見ててくれたのでPCがっつり開ける!ということでこないだ技術書典で購入したRails8入門を参考に触ってみました。

環境構築に関しては、本だとrails-newアプリとdevcontainerで環境構築しているのですが私はローカルで構築してます。理由としては今までの案件ではこの方法の方が多かったので、ひとまずはこっちの方法で試してみるかと思ったためです。
今度、rails-newアプリとdevcontainerのパターンでやるとどれだけ早いのか比較してみようと思います。(ちなみにローカル環境の構築は少しハマったのもあり2hくらいかかりました)

おそらく環境に依存せずサクッと構築するにはrails-newアプリとdevcontainerのパターンがお手軽なのかなと推測しています。

久しぶりに個人PCでrails newしたので備忘録も兼ねて環境構築部分からメモを残しています。

環境

M1 Mac(OS: Ventura)
ruby 3.3.6
PostgreSQL 14.14

環境構築

rubyのバージョン管理はasdfを使用しています。
https://qiita.com/murakami-mm/items/2d63177dc8ea002a847b

asdf install ruby 3.3.6の際に途中 BUILD FAILED (macOS 13.0.1 on arm64 using ruby-build 20241105)が出たが、下記記事を参考に環境変数を~/.zshrc にセットすることで解決

.zshrc
export RUBY_CONFIGURE_OPTS="--with-openssl-dir=$(brew --prefix openssl@1.1)"
export LDFLAGS="-L/opt/homebrew/opt/readline/lib"
export CPPFLAGS="-I/opt/homebrew/opt/readline/include"
export PKG_CONFIG_PATH="/opt/homebrew/opt/readline/lib/pkgconfig"
export optflags="-Wno-error=implicit-function-declaration"
export LDFLAGS="-L/opt/homebrew/opt/libffi/lib"
export CPPFLAGS="-I/opt/homebrew/opt/libffi/include"
export PKG_CONFIG_PATH="/opt/homebrew/opt/libffi/lib/pkgconfig"
export RUBY_CFLAGS="-w"
% mkdir rails8_test
% cd rails8_test
% asdf latest ruby
% asdf install ruby 3.3.6
% asdf local ruby 3.3.6

久しぶりなものでrails newからプロジェクト作る方法をググる・・・
https://qiita.com/yuitnnn/items/b45bba658d86eabdbb26

rails8_test % bundle init
Writing new Gemfile to /Users/xxxx/Jobs/rails8_test/Gemfile

# で、Gemfileのgem "rails"のコメントアウトを外す

# % cat ~/.bundle/config
# ---
# BUNDLE_PATH: "vendor/bundle"
# ↑の設定なのでオプションはつけてません

rails8_test % bundle install

きちんとrailsのバージョンが8でインストールされているのでよし

Gemfile.lock
    rails (8.0.0)
rails8_test % bundle exec rails new . -B -d postgresql --skip-test
       exist  
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes
    conflict  Gemfile
Overwrite /Users/sagawaai/Jobs/rails8_test/Gemfile? (enter "h" for help)  # 先にGemfileいじらなくても良かった。上書き[Ynaqdhm] Y
       force  Gemfile
         run  git init -b main from "."

rails8_test % bin/rails s
Could not find gem 'propshaft' in locally installed gems.
Run `bundle install --gemfile /Users/sagawaai/Jobs/rails8_test/Gemfile` to install missing gems.

rails8_test % bundle install --gemfile /Users/sagawaai/Jobs/rails8_test/Gemfile

# この後psql関連のエラーが出てdb:createできなかったので下記を参考に解消
https://loveenglish.hatenablog.com/entry/2023/08/04/111831

rails8_test % bin/rails db:create                
Created database 'rails8_test_development'
Created database 'rails8_test_test'

rails8_test % bin/rails s

🎉 (スクショのリンクがamazonaws・・・QiitaはAWSなんだ←どうでもいい)

スクリーンショット 2024-11-16 13.46.23.png

Rails8の認証機能試してみる!

ここからはRails8の機能を色々試していきたいなというところなので、技術書典17で購入した下記の本を参考に進めていこうと思ってます。(第3章〜)

今回は認証機能に絞って色々確認してみました。

Ruby on Rails 8 入門
吉田智哉 著
https://x.com/tomoya_y0shida?lang=fa
https://techbookfest.org/product/41bEtMuRb2xADJH5E0WWVC?productVariantID=2hufyTuCnbtJu8bCHDG6pU

本ではジェネレーターで作成されるデフォルトの機能とそうでない機能も整理されていて、何ができて何ができないか(= 自前実装が必要か)の基準になるのでありがたかったです。

以下引用

注意点としては、現時点で認証機能は以下の機能のみサポートしています。
• ログイン (Authenticatable)
• ログアウト (Authenticatable)
• パスワードリセット (Recoverable)
• IP アドレスとログイン時刻の記録 (Trackable)
• ログイン回数の制限 (Lockable)

サインアップや Devise では標準でサポートされている以下の機能はサポートされてい ません。
• メール認証 (Confirmable)
• ソーシャルログイン (Omniauthable)
• ログインの記憶 (Rememberable)
• IP のトラッキング (Trackable)
• バリデーション (Validatable)
• セッションの時間制限 (Timeoutable)
これらの機能の実装は自分で追加する必要があります。

1. 認証ジェネレータで作成される機能

ログイン機能作成

第3章ではジェネレーターを使った設定方法を説明してくれてます。
意外に作られるファイルが少ない。
テーブルもUserとSession管理用のみ

rails8_test % 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
/Users/sagawaai/.asdf/installs/ruby/3.3.6/bin/ruby: No such file or directory -- bin/bundle (LoadError)
    generate  migration CreateUsers email_address:string!:uniq password_digest:string! --force
       rails  generate migration CreateUsers email_address:string!:uniq password_digest:string! --force 
Could not find gem 'bcrypt (~> 3.1.7)' in locally installed gems.
Run `bundle install --gemfile /Users/sagawaai/Jobs/rails8_test/Gemfile` to install missing gems.

# パスワードをハッシュ化するためgem 'bcrypt(~> 3.1.7)'を有効にする(= Gemfileのコメントアウトを外す)
rails8_test % bundle install --gemfile /Users/sagawaai/Jobs/rails8_test/Gemfile
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Fetching bcrypt 3.1.20
Installing bcrypt 3.1.20 with native extensions
WARN: Unresolved or ambiguous specs during Gem::Specification.reset:
      stringio (>= 0)
      Available/installed versions of this gem:
      - 3.1.2
      - 3.1.1
WARN: Clearing out unresolved specs. Try 'gem cleanup <gem>'
Please report a bug if this causes problems.
Bundle complete! 20 Gemfile dependencies, 106 gems now installed.
Bundled gems are installed into `./vendor/bundle`
rails8_test % bin/rails generate authentication                                
      invoke  erb
   identical    app/views/passwords/new.html.erb
   identical    app/views/passwords/edit.html.erb
   identical    app/views/sessions/new.html.erb
   identical  app/models/session.rb
   identical  app/models/user.rb
   identical  app/models/current.rb
   identical  app/controllers/sessions_controller.rb
   identical  app/controllers/concerns/authentication.rb
   identical  app/controllers/passwords_controller.rb
   identical  app/channels/application_cable/connection.rb
   identical  app/mailers/passwords_mailer.rb
   identical  app/views/passwords_mailer/reset.html.erb
   identical  app/views/passwords_mailer/reset.text.erb
   identical  test/mailers/previews/passwords_mailer_preview.rb
   unchanged  app/controllers/application_controller.rb
       route  resources :passwords, param: :token
       route  resource :session
        gsub  Gemfile
      bundle  install --quiet
/Users/sagawaai/.asdf/installs/ruby/3.3.6/bin/ruby: No such file or directory -- bin/bundle (LoadError)
    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/20241116053429_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/20241116053431_create_sessions.rb

rails8_test % bin/rails db:migrate

# Userテーブルを作成している
== 20241116053429 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0081s
-- add_index(:users, :email_address, {:unique=>true})
   -> 0.0163s
== 20241116053429 CreateUsers: migrated (0.0246s) =============================

# セッション管理用テーブルを作成している
== 20241116053431 CreateSessions: migrating ===================================
-- create_table(:sessions)
   -> 0.0088s
== 20241116053431 CreateSessions: migrated (0.0089s) ==========================

rails8_test % bin/rails g controller home index
      create  app/controllers/home_controller.rb
       route  get "home/index"
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      invoke  helper
      create    app/helpers/home_helper.rb

rails8_test % bin/rails db:seed

作成されたテーブル

schema.rb
  create_table "sessions", force: :cascade do |t|
    t.bigint "user_id", null: false
    t.string "ip_address"
    t.string "user_agent"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_sessions_on_user_id"
  end

  create_table "users", force: :cascade do |t|
    t.string "email_address", null: false
    t.string "password_digest", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.integer "confirmation_status", default: 1, null: false
    t.string "confirmation_token", limit: 64
    t.datetime "expiration_date"
    t.index ["email_address"], name: "index_users_on_email_address", unique: true
  end

とりあえず下記のファイルが重要そうかなと思いました

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

  included do
    before_action :require_authentication
    helper_method :authenticated?
  end

  class_methods do
    def allow_unauthenticated_access(**options)
      skip_before_action :require_authentication, **options
    end
  end

  private
    def authenticated?
      resume_session
    end

    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

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


    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 terminate_session
      Current.session.destroy
      cookies.delete(:session_id)
    end
end

ポイント

パスワー ドは平文ではなく、ハッシュ化して保存するために password_digest というカラムを追 加します。xxx_digest というカラム名にしておくことで、この後に追加する has_secu re_password メソッドを使ってパスワードをハッシュ化して保存することができます。

app/models/user.rb
class User < ApplicationRecord
  has_secure_password

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

その他としては・・・

  • CurrentAttributes でリクエストごとの情報を管理している。現在ログイン中のユーザー情報を取得は、Current.user で取得できる
  • sessionを作成して、Current.session にセットしている(start_new_session_forの処理・ログインの度にsession情報新規作成)
  • 作成したsessionのidをsigned署名付きのcookieに保存している(start_new_session_forの処理)

ログアウト機能

terminate_sessionでログアウトしてる

app/controllers/concerns/authentication.rb
    def terminate_session
      Current.session.destroy
      cookies.delete(:session_id)
    end
app/controllers/sessions_controller.rb
  def destroy
    terminate_session # ApplicationControllerでinclude Authenticationされている
    redirect_to new_session_path
  end

パスワードリセット

パスワードリセットメール用プレビューの処理がデフォであるのはいいなと思った
実際の送信確認はletter_openerとか利用した方がいいのだろうけど、ひとまずメール文面の見栄えとかを確認したい際にはいいかな

test/mailers/previews/passwords_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
  # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
  def reset
    PasswordsMailer.reset(User.take)
  end
end

スクリーンショット 2024-11-16 16.13.40.png

IP アドレスとログイン時刻の記録 (Trackable)

IP アドレスとログイン時刻の記録 (Trackable)は下記のようにsession情報から取得可能
ログイン/ログアウト時にも触れていたがログインするとsession情報が作成されて、ログアウトすると削除される動きだった。
そして、またログインすると新しいレコードができる。
複数sessionなる場合ってどんな時だろ?(ログアウト機能作ってなくて何回もログインした時は複数作られてたけど、基本的にはそんなことはないだろうし。)

[4] pry(main)> user.sessions
  Session Load (0.5ms)  SELECT "sessions".* FROM "sessions" WHERE "sessions"."user_id" = 1 /* loading for pp */ LIMIT 11 /*application='Rails8Test'*/
=> [#<Session:0x000000011f879d88
  id: 4,
  user_id: 1,
  ip_address: "xxxx",
  user_agent: "xxxxx",
  created_at: "2024-11-16 07:07:23.701156000 +0000",
  updated_at: "2024-11-16 07:07:23.701156000 +0000">]

ソースで言うとこの辺り

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

ログイン回数の制限 (Lockable)

認証ジェネレータはレートリミット機能を使ってログイン回数の制限をかけてい ます。デフォルトでは 3 分間に 10 回までログインを試行できます。

とのこと。

この辺りの処理でレートリミットの詳細は下記記事がわかりやすかった
これログインに限らず、カート購入処理の回数制限等々いろんな箇所で使えるのでは?と思った
https://techracho.bpsinc.jp/hachi8833/2024_02_20/139497

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: "Try again later." }

2. 認証ジェネレーターで作成されない機能

まさかのサインアップがないんだなー

サインアップ機能

第7章で紹介してくれてました。
ポイントは認証前(= 未ログイン)でもページを表示できるように、RegistrationsControllerでallow_unauthenticated_access を設定しているとこ

登録時のメール認証機能

deviseだと:confirmableを設定すればいいのだけど、今回はデフォルトでは備わっていないので簡易的な認証メール機能を追加してみた。Lintで指摘されているのはスルーでお願いします・・・

  • トークンとステータス(メール認証した・してない)、トークン有効期限のカラム追加
db/migrate/20241117071624_add_confirmation_to_users.rb
class AddConfirmationToUsers < ActiveRecord::Migration[8.0]
  def change
    add_column :users, :confirmation_status, :integer, default: 1, null: false
    add_column :users, :confirmation_token, :string, limit: 64
    add_column :users, :expiration_date, :datetime
  end
end
rails8_test % bin/rails g migration add_confirmation_to_users
rails8_test % bin/rails db:migrate
== 20241117071624 AddConfirmationToUsers: migrating ===========================
-- add_column(:users, :confirmation_status, :integer, {:default=>1, :null=>false})
   -> 0.0068s
-- add_column(:users, :confirmation_token, :string, {:limit=>64})
   -> 0.0013s
-- add_column(:users, :expiration_date, :datetime)
   -> 0.0013s
== 20241117071624 AddConfirmationToUsers: migrated (0.0095s) ==================

rails8_test % bin/rails g mailer User       
      create  app/mailers/user_mailer.rb
      invoke  erb
      create    app/views/user_mailer

  • ユーザー登録時に確認トークンを生成
app/models/user.rb
class User < ApplicationRecord
  EMAIL_CONFIRMATION_LIMIT = 5.minutes # 実案件では各案件ルールに従った定数の設定が良い

  before_create :generate_confirmation_token

  enum :confirmation_status, { confirmed: 0, unconfirmed: 1 }

    private

  # トークンとトークン期限の設定
  def generate_confirmation_token
    self.confirmation_token = SecureRandom.urlsafe_base64(47)
    self.expiration_date = Time.zone.now + EMAIL_CONFIRMATION_LIMIT
  end
  • 確認メールを送信
app/controllers/registrations_controller.rb
  def create
    @user = User.new(params.permit(:email_address, :password))
    if @user.save
      UserMailer.send_email_confirmation(@user).deliver_later
      redirect_to root_path, notice: '登録したメールアドレスに確認メールを送信しました。'
    else
      flash.now[:alert] = "Failed to sign up"
      render :new
    end
  end
  • トークン付きのURLでメール認証
app/controllers/registrations_controller.rb
    # メール認証用アクション
  def confirm_email
    @user = User.find_by(confirmation_token: params[:token])

    # 未確認状態かつトークン有効期間内
    if @user.unconfirmed? && !@user.expired?
      @user.confirm!
      redirect_to root_path, notice: "メールアドレスの確認が完了しました"
    else
      redirect_to root_path, alert: "無効なトークンです"
    end
  end
app/views/user_mailer/send_email_confirmation.text.erb
以下のリンクをクリックしてメールアドレスの認証を行ってください。
<%= link_to "メールアドレスを確認する", confirm_email_registration_url(token: @user.confirmation_token) %>

他にもデフォルトで備わっていない機能はあるが今回はこの辺で。

まとめ

DHH氏が

プログラマは認証についてなにが起きているのかを知らなければならないとして、Rails 8では認証機能をコードの生成によって提供する

と言っていたとのことなので、今までDeviseでブラックボックスになっていた部分がシンプルなコードで確認できるようになったのはいいなと思いました。

ただ、機能の豊富さはDevise等gemの方がまだ多いので要件によっては従来のgemで対応していくこともまだまだあるかなと思いました。

その他 機能

今回は認証機能のみに絞って触ってみましたが他にも色々と機能追加がされたようなので、また後日確認できたらなーと

  • Kamal2
  • Thurster
  • SprocketsがPropshaftに置き換わった
  • Solidツール群:RedisとかSidekiqが不要になるのかな?

あとEC案件を多く扱ってきた身としては次のRails8.1のActive Record Searchは気になります。
https://www.publickey1.jp/blog/24/dhhrails_8redissqliterails_world_2024.html

デフォルトでGitHub CIの設定が入っている

これはGitHubにpushしたときにたまたま気づいた

.github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  scan_ruby:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Scan for common Rails security vulnerabilities using static analysis
        run: bin/brakeman --no-pager

  scan_js:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Scan for security vulnerabilities in JavaScript dependencies
        run: bin/importmap audit

  lint:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: .ruby-version
          bundler-cache: true

      - name: Lint code for consistent style
        run: bin/rubocop -f github

不要な人は--skip-ciすれば作成されない

 rails8_test % rails new --help

   -T,            [--skip-test]                                                 # Skip test files
                 [--skip-system-test]                                          # Skip system test files
                 [--skip-bootsnap]                                             # Skip bootsnap gem
                 [--skip-dev-gems]                                             # Skip development gems (e.g., web-console)
                 [--skip-thruster]                                             # Skip Thruster setup
                 [--skip-rubocop]                                              # Skip RuboCop setup
                 [--skip-brakeman]                                             # Skip brakeman setup
                 [--skip-ci]                                                   # Skip GitHub CI files

余談

当初turboが効いておらず<%= link_to "Logout", session_path, data: { turbo_method: :delete } %> でsession#showに飛んでしまいエラーとなっていた
簡易的なテストシステムなのでimportmapを利用
https://www.airteams.net/media/articles/2394

必要なgem

gem "turbo-rails"
gem "importmap-rails"

必要なファイル構成

app/
  javascript/
    application.js  # Turboのインポート
config/
  importmap.rb     # JavaScriptのマッピング
vendor/
  javascript/      # Turboのライブラリファイル

application.html.erbで読み込み

    <%= javascript_importmap_tags %>
    <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
  </head>

app/javascript/application.js でimport

app/javascript/application.js
import "@hotwired/turbo-rails"
rails8_test % bin/rails importmap:install
       apply  /Users/sagawaai/Jobs/rails8_test/vendor/bundle/ruby/3.3.0/gems/importmap-rails-2.0.3/lib/install/install.rb
  Add Importmap include tags in application layout
   unchanged    app/views/layouts/application.html.erb
  Create application.js module as entrypoint
      create    app/javascript/application.js
  Use vendor/javascript for downloaded pins
      create    vendor/javascript
      create    vendor/javascript/.keep
  Configure importmap paths in config/importmap.rb
      create    config/importmap.rb
  Copying binstub
      create    bin/importmap
         run  bundle install --quiet

他参考

https://zenn.dev/webuilder240/scraps/1038b4d469ed7d
https://techracho.bpsinc.jp/hachi8833/2024_10_21/145343

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?