RubyのOpenStrictクラスの使い方をGemのコードで理解を試みる

こんにちは!kossyです!




今回は、RubyのOpenStrictクラスの使い方を saml-idp という Gem のコード内での使い方を見て、理解を深めてみたいと思います。




ドキュメントを見る

公式のOpenStrictクラスのドキュメントを見てみます。

docs.ruby-lang.org

要素を動的に追加・削除できる手軽な構造体を提供するクラスです。
OpenStruct のインスタンスに対して未定義なメソッド x= を呼ぶと、 OpenStruct クラスの BasicObject#method_missing で捕捉され、そのインスタンスインスタンスメソッド x, x= が定義されます。
この挙動によって要素を動的に変更できる構造体として働きます。

出典: https://docs.ruby-lang.org/ja/latest/class/OpenStruct.html

「動的にメソッドを生やしたり削除したりできる」ところを抑えればよさそうですね。

かなり実装の自由度が高いように感じますが、saml-idp Gem 内でどのように使われているかを見てみます。


SamlIdp::Configurator

github.com

# encoding: utf-8
require 'ostruct'
require 'securerandom'

module SamlIdp
  class Configurator
    attr_accessor :x509_certificate
    attr_accessor :secret_key
    attr_accessor :password
    attr_accessor :algorithm
    attr_accessor :organization_name
    attr_accessor :organization_url
    attr_accessor :base_saml_location
    attr_accessor :entity_id
    attr_accessor :reference_id_generator
    attr_accessor :attribute_service_location
    attr_accessor :single_service_post_location
    attr_accessor :single_service_redirect_location
    attr_accessor :single_logout_service_post_location
    attr_accessor :single_logout_service_redirect_location
    attr_accessor :attributes
    attr_accessor :service_provider
    attr_accessor :assertion_consumer_service_hosts
    attr_accessor :session_expiry

    def initialize
      self.x509_certificate = Default::X509_CERTIFICATE
      self.secret_key = Default::SECRET_KEY
      self.algorithm = :sha1
      self.reference_id_generator = ->() { SecureRandom.uuid }
      self.service_provider = OpenStruct.new
      self.service_provider.finder = ->(_) { Default::SERVICE_PROVIDER }
      self.service_provider.metadata_persister = ->(id, settings) {  }
      self.service_provider.persisted_metadata_getter = ->(id, service_provider) {  }
      self.session_expiry = 0
      self.attributes = {}
    end

    # formats
    # getter
    def name_id
      @name_id ||= OpenStruct.new
    end

    def technical_contact
      @technical_contact ||= TechnicalContact.new
    end

    class TechnicalContact < OpenStruct
      def mail_to_string
        "mailto:#{email_address}" if email_address.to_s.length > 0
      end
    end
  end
end

initializeメソッド内で service_provider 属性に対して、OpenStrictクラスのインスタンスが代入されています。

service_providerに対して未定義のメソッドを呼び出す箇所は、ReadMeのConfigration Sectionに記載されていました。

github.com

  # `identifier` is the entity_id or issuer of the Service Provider,
  # settings is an IncomingMetadata object which has a to_h method that needs to be persisted
  config.service_provider.metadata_persister = ->(identifier, settings) {
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    File.open Rails.root.join("cache/saml/metadata/#{fname}"), "r+b" do |f|
      Marshal.dump settings.to_h, f
    end
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # `service_provider` is a ServiceProvider object. Based on the `identifier` or the
  # `service_provider` you should return the settings.to_h from above
  config.service_provider.persisted_metadata_getter = ->(identifier, service_provider){
    fname = identifier.to_s.gsub(/\/|:/,"_")
    FileUtils.mkdir_p(Rails.root.join('cache', 'saml', 'metadata').to_s)
    full_filename = Rails.root.join("cache/saml/metadata/#{fname}")
    if File.file?(full_filename)
      File.open full_filename, "rb" do |f|
        Marshal.load f
      end
    end
  }

  # Find ServiceProvider metadata_url and fingerprint based on our settings
  config.service_provider.finder = ->(issuer_or_entity_id) do
    service_providers[issuer_or_entity_id]
  end

metadata_persister と persisted_metadata_getter と finder メソッドが動的に定義されていました。

name_idメソッドも実体はOpenStrictインスタンスなので、使用例を見てみましょう。

  # Principal (e.g. User) is passed in when you `encode_response`
  #
  # config.name_id.formats =
  #   {                         # All 2.0
  #     email_address: -> (principal) { principal.email_address },
  #     transient: -> (principal) { principal.id },
  #     persistent: -> (p) { p.id },
  #   }
  #   OR
  #
  #   {
  #     "1.1" => {
  #       email_address: -> (principal) { principal.email_address },
  #     },
  #     "2.0" => {
  #       transient: -> (principal) { principal.email_address },
  #       persistent: -> (p) { p.id },
  #     },
  #   }

TechnicalContactクラスもOpenStrictクラスを継承したクラスとして定義されていて、こちらも動的に定義している箇所がありました。

  ## EXAMPLE ##
  # config.attributes = {
  #   GivenName: {
  #     getter: :first_name,
  #   },
  #   SurName: {
  #     getter: :last_name,
  #   },
  # }
  ## EXAMPLE ##

  # config.technical_contact.company = "Example"
  # config.technical_contact.given_name = "Jonny"
  # config.technical_contact.sur_name = "Support"
  # config.technical_contact.telephone = "55555555555"
  # config.technical_contact.email_address = "example@example.com"

こちらはユーザーが自由に定義できるユーザー情報を入れることを期待しているようですね。


service_provider属性にOpenStrictクラスを使う理由

これは個人的な推測ですが、 SAML認証のServiceProviderとして設定できる値は十数以上あり、

「その全てをServiceProviderクラスとして定義してしまうよりは、ユーザー側に必要な値だけ定義させれば良い」

という考えでservice_provider属性にOpenStrictクラスを使ったのかと思われます。

リンク先はruby-saml Gem で service_providerに設定できる値です。

github.com


まとめ

OpenStrictクラスを使ったコードを読んでみましたが、

「クラスとしてガチガチに定義するほどでもない時」
「使う側にメソッドを動的に定義させたい時」
「具体的にクラスに持たせたい属性が決まってない時」

などで使えるのではないかと思いました。

sendgridでメールの一斉配信をする際に宛先ごとに異なる文字列を仕込む方法

こんにちは!kossyです!




さて、今回はsendgridでメールの一斉配信をする際に宛先ごとに異なる文字列を仕込む方法について、ブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4




X-SMTPAPIの Substitution Tags を使う

以下のドキュメントによると、

sendgrid.kke.co.jp

宛先ごとに「ようこそ!◯◯様」などといった形で宛名やその他様々な属性値をメール本文などに埋め込みたい場合、Substitution Tagsが利用できます。
次のように、「sub」パラメータで置換キー(-name-)と宛先毎の置換文字列([“Alice”,”Bob”])を配列で指定します。
配列の順序は「to」パラメータで指定した宛先配列の順序と対応づいています。

{
  "to" : [
    "alice@test.com",
    "bob@test.com"
  ],
  "sub": {
    "-name-": [
      "Alice",
      "Bob"
   ]
}

一方、メール本文には置換したい箇所に置換キーを埋め込みます。

ようこそ!-name-様

こうすることで、このリクエストはSendGridで処理され、宛先ごとに分解された上で、置換キーが宛先ごとに対応する文字列で置き換えられます。
また、Section Tagsを利用することでより高度な置換処理を指定することもできます。

出典: https://sendgrid.kke.co.jp/blog/?p=4232

との記載がありますので、この機能を使えば宛先ごとに異なる文字列をメール本文に挿入することができそうです。

Railsの場合ですと、以下のようなコードを記述すればOKです。

  def send_mail
    users = User.all

    emails = users.pluck(:email)
    names = users.pluck(&:full_name)

    xsmtp_api_params = { to: emails, sub: { "-name-": names } }
    headers['X-SMTPAPI'] = JSON.generate(xsmtp_api_params)

    mail(to: emails, subject: subject)
  end

置換コードである「-name-」はerbテンプレートに仕込んでおきます。

<p>ようこそ! -name- 様</p>

これで宛先ごとに異なる文字列をメール本文内に挿入することができます。


まとめ

メールの一斉配信の際に宛先ごとに異なる文字列を仕込めるのは便利っちゃ便利なんですが、パラメータの指定を間違えたりすると重大なインシデントにつながってしまうので、

誤りの入り込まないパラメータ構築を行う必要がありそうですね。。。

ruby-samlでX509証明書の有効期限を取得したい

こんにちは!kossyです!



今回は ruby-saml でX509証明書の有効期限を取得する方法について、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4
ruby-saml 1.13.0




not_afterメソッド

結論から言うと、OpenSSL::X509::Certificate#not_afterメソッドを呼び出せばOKです。

docs.ruby-lang.org

OneLogin::RubySaml::Settings#get_idp_certで取得した OpenSSL::X509::Certificate インスタンスに対して、not_afterメソッドを実行すればOKです。

github.com

$ settings = Account.get_saml_settings('http://localhost:3000')

$ certificate = settings.get_idp_cert
=> #<OpenSSL::X509::Certificate
 subject=#<OpenSSL::X509::Name CN=IDPNAME,L=SEONGNAM,C=JP>,
 issuer=#<OpenSSL::X509::Name CN=IDPNAME,L=SEONGNAM,C=JP>,
 serial=#<OpenSSL::BN 12343304055179801234>,
 not_before=2021-09-30 04:45:51 UTC,
 not_after=2026-09-29 04:45:51 UTC>

$ certificate.not_after
=> 2026-09-29 04:45:51 UTC

ちなみに、ruby-samlには有効期限が切れているかどうかをチェックするメソッドが実装されています。

module OneLogin
  module RubySaml

    # SAML2 Auxiliary class
    #
    class Utils
      @@uuid_generator = UUID.new if RUBY_VERSION < '1.9'

      DSIG      = "http://www.w3.org/2000/09/xmldsig#"
      XENC      = "http://www.w3.org/2001/04/xmlenc#"
      DURATION_FORMAT = %r(^(-?)P(?:(?:(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?)|(?:(\d+)W))$)

      # Checks if the x509 cert provided is expired
      #
      # @param cert [Certificate] The x509 certificate
      #
      def self.is_cert_expired(cert)
        if cert.is_a?(String)
          cert = OpenSSL::X509::Certificate.new(cert)
        end

        return cert.not_after < Time.now
      end

# 省略

なお、ruby-samlでは証明書の有効期限チェックはデフォルトでfalseになっているので、もし有効期限チェックをGem側で行いたい場合は

明示的に true にしておく必要があります。

github.com


まとめ

not_before属性とnot_after属性を使用すれば簡単に証明証の有効期限のバリデーションがかけられそうです。

deviseのencrypted_passwordに値が保存される仕組みを調べてみた

こんにちは!kossyです!




今回はdeviseで認証機能を利用する際に必要になるカラムである、「encrypted_password」カラム に値が保存される仕組みを調べてみたので、

備忘録としてブログに残してみたいと思います。




環境
Ruby 3.0.3
Rails 6.0.4
devise 4.8.1





なお、既にdeviseを利用しているUserモデルが定義されていて、デバッグ用のGem2種類(pry-rails & pry-byebug)がinstallされていることとします。


ユーザー作成時の動作を見てみる

まずはコンソールでユーザーを作成する際の挙動を見てみます。

# Userクラスのソースコード
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable
end

# コンソール
$ user = User.create(email: 'sample-user@example.or.jp', password: 'test1234')
   (0.5ms)  BEGIN
  User Exists? (11.4ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "sample-user@example.or.jp"], ["LIMIT", 1]]
  User Create (9.0ms)  INSERT INTO "users" ("email", "encrypted_password", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["email", "sample-user@example.or.jp"], ["encrypted_password", "$2a$12$OlpguVbwkOb6BuZ4dxXV8.XP/Byqb9TSu6ot23cwpHssOGSrIS6Vu"], ["created_at", "2021-12-26 07:35:11.543708"], ["updated_at", "2021-12-26 07:35:11.543708"]]
   (3.8ms)  COMMIT

Userクラスに何か特別なメソッドを生やしたわけではありませんが、レコードのcreate時にeccrypted_passwordカラム に値が入っていることがわかります。

なので、deviseのmoduleのいずれかのソースコードを確認すればよさそうです。

database_authenticatableでencrypted_passwordの挿入を行なっていそうなので、見に行ってみます。

database_authenticatable

github.com

コメントアウト部分を訳してみます。

Authenticatable Module, responsible for hashing the password and
validating the authenticity of a user while signing in.

This module defines a `password=` method. This method will hash the argument
and store it in the `encrypted_password` column, bypassing any pre-existing
`password` column if it exists.

Authenticatable Moduleは、パスワードのハッシュ化と、サインイン中のユーザーの信頼性の検証を担当します。

このモジュールは、 `password =`メソッドを定義します。 このメソッドは、引数をハッシュ化して `encrypted_password`列に格納し、
既存の` password`列が存在する場合はそれをバイパスします。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

password=メソッドが呼ばれた際にencrypted_passwordに値が入るので、ここでこの記事を終わりにしてもいいんですが、それでは味気ないので、

もっと深くコードを追ってみましょう。


password=

github.com

# Generates a hashed password based on the given value.
# For legacy reasons, we use `encrypted_password` to store
# the hashed password.

指定された値に基づいてハッシュ化されたパスワードを生成します。
歴史的な理由から、ハッシュ化されたパスワードを保存するために `encrypted_password`を使用します。

def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

コメントアウトの「For legacy reasons」が気になりますね。

git blameでcommitメッセージを見てみましょう。

Change encryption for hashing in the documentation.
Throughout the documentations, we are using 'encrypt' incorrectly.
Encrypt means that someone will eventually decrypt the message,
which is obviously not the case for Devise.

I'm changing the docs to use 'hashing' instead.

However, I left the database field as `encrypted_password` for now.
I'll update the db field in an upcoming PR.

ドキュメントのハッシュの暗号化を変更します。
ドキュメント全体を通して、「encrypt」を誤って使用しています。
暗号化とは、誰かが最終的にメッセージを復号化することを意味します。
これは明らかにDeviseには当てはまりません。

代わりに「ハッシュ」を使用するようにドキュメントを変更しています。

ただし、今のところ、データベースフィールドは `encrypted_password`のままにしておきます。
今後のPRでdbフィールドを更新します。

出典: Change encryption for hashing in the documentation. · heartcombo/devise@c4b4411 · GitHub

なるほど、encryptの意味を誤用しているから、本当はhashed_passwordにカラム名を変更したいけど、

このcommitでは一旦encrypted_passwordにしておいたとのことです。

コメントアウトとcommitメッセージの意図が汲み取れたところで、ソースコードを見てみます。

def password=(new_password)
  @password = new_password
  self.encrypted_password = password_digest(@password) if @password.present?
end

引数のnew_passwordをインスタンス変数とし、@passwordがpresentであれば、password_digestをencrypted_passwordの値としているようです。

password_digestメソッドを見る必要がありそう。


password_digest

github.com

# Hashes the password using bcrypt. Custom hash functions should override
# this method to apply their own algorithm.
#
# See https://github.com/heartcombo/devise-encryptable for examples
# of other hashing engines.
def password_digest(password)
  Devise::Encryptor.digest(self.class, password)
end

コメントアウト部分を訳してみます。

Hashes the password using bcrypt. Custom hash functions should override this method to apply their own algorithm.

bcryptを使用してパスワードをハッシュ化します。 カスタムハッシュ関数は、独自のアルゴリズムを適用するためにこのメソッドをオーバーライドする必要があります。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb

パスワードハッシュアルゴリズムであるbcryptを使用して引数のpasswordをハッシュ化してくれるメソッドのようです。

コンソールで試してみます。

> user = User.first

> password = 'test1234'

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$N3Vh/XXNu6rHPMLxBd1LMeSzH.c3rQWl6EndVZQotaQRIRU3gmrDy"

> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$A4G7CAQb50llKJ.oyJauEu6J6PoK7UZrNtN7In9AicIvyPIf5fZ5y"

# 何度実行しても同じ値は返らない
> Devise::Encryptor.digest(user.class, password)
=> "$2a$12$klOb/T0Z/ryo9U.Z2SFbPO8f7zOYs47Om/Ea9GGncb53OHve2E6Ya"

passwordは一緒でも同じ値は返らないことが確認できますね。


regisrations_controller.rbのcreateアクションの動作を追ってみる

https://github.com/heartcombo/devise/blob/main/app/controllers/devise/regisrations_controller.rbgithub.com

どんな処理を経て最終的にユーザーが作成されるのかもついでに見てみます。

  # POST /resource
  def create
    build_resource(sign_up_params)

    resource.save
    yield resource if block_given?
    if resource.persisted?
      if resource.active_for_authentication?
        set_flash_message! :notice, :signed_up
        sign_up(resource_name, resource)
        respond_with resource, location: after_sign_up_path_for(resource)
      else
        set_flash_message! :notice, :"signed_up_but_#{resource.inactive_message}"
        expire_data_after_sign_in!
        respond_with resource, location: after_inactive_sign_up_path_for(resource)
      end
    else
      clean_up_passwords resource
      set_minimum_password_length
      respond_with resource
    end
  end

build_resource

github.com

# Build a devise resource passing in the session. Useful to move
# temporary session data to the newly created user.

セッションで渡すデバイスリソースを構築します。
 一時的なセッションデータを新しく作成したユーザーに移行するのに便利です。

def build_resource(hash = {})
  self.resource = resource_class.new_with_session(hash, session)
end

resource_classはサインアップしようとしているモデルのクラスが返ります。

次にnew_with_sessionメソッドを確認してみます。

new_with_session

github.com

# A convenience method that receives both parameters and session to
# initialize a user. This can be used by OAuth, for example, to send
# in the user token and be stored on initialization.
#
# By default discards all information sent by the session by calling
# new with params.
def new_with_session(params, session)
  new(params)
end

コメントアウト部分を訳してみます。

A convenience method that receives both parameters and session to initialize a user.
This can be used by OAuth, for example, to send in the user token and be stored on initialization.
By default discards all information sent by the session by calling new with params.

ユーザーを初期化するためにパラメーターとセッションの両方を受け取る便利なメソッド。
これは、たとえば、ユーザートークンを送信し、初期化時に保存するためにOAuthで使用できます。
デフォルトでは、paramsを使用してnewを呼び出すことにより、セッションによって送信されたすべての情報を破棄します。

出典: https://github.com/heartcombo/devise/blob/main/lib/devise/models/registerable.rb

メソッド名の通り、パラメーターとセッションの両方を受け取って処理を行うメソッドですね。

REPLで確認してみます。

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/registerable.rb:22 Devise::Models::Registerable::ClassMethods#new_with_session:

    21: def new_with_session(params, session)
 => 22:   new(params)
    23: end

[1] pry(User)> params
=> {"email"=>"test-user-1@example.or.jp", "password"=>"test1234", "password_confirmation"=>"141b3ccef657"}
[2] pry(User)> session
=> #<ActionDispatch::Request::Session:0x00007f300842df80 ...>
[3] pry(User)> session.to_h
=> {"session_id"=>"b71549ad67aa9d67e27aa04fea2c0b37", "_csrf_token"=>"KbKDaYOjWm7jMLPfqctmdhG3ImpMGoQ4y9JX89plxLc="}

paramsはユーザー登録時に入力した値が格納されていて、sessionはActionDispatch::Request::Sessionクラスのインスタンスでした。

newメソッドは ActiveRecord::Inheritance::ClassMethods#new が実行されるようです。


resource=

nextメソッドで処理を進めるとresource=メソッドがcallされました。

From: /usr/local/bundle/gems/devise-4.8.0/app/controllers/devise_controller.rb:95 DeviseController#resource=:

    94: def resource=(new_resource)
 => 95:   instance_variable_set(:"@#{resource_name}", new_resource)
    96: end

このメソッドが実行されると、以降は resource でユーザーを取得することができます。

ここでbuild_resourceの処理は終わりでした。


まとめ

deviseのencrypted_passwordに値が保存される仕組みを一言で言うと、

「password属性に値が入るタイミングでecnrypted_passwordカラムにBCryptアルゴリズムによってハッシュ化された文字列が代入される」

でしたね。

また、「encryptの意味を誤用しているからencrypted_passwordという命名を変えた方がいい」という話があることは知りませんでした。

上記の変更が2014年に加わっていて、その後encrypted_passwordという名前が変わっていないことを考えると、

既にdeviseを使ったアプリケーションが何万とある中で、後方互換性を維持したまま変更を加えるのが大変なんでしょう、、、

命名には細心の注意を払うようにしましょう、、、

RailsでPostgreSQLを使う際にgen_random_uuid関数を有効化したい

こんにちは!kossyです!




今回はRailsPostgreSQLを使う際にgen_random_uuid関数を有効化する方法について、

備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.9
Rails 6.0.4
PostgreSQL 13系



マイグレーションファイルの作成

gen_random_uuid関数を有効化するために、マイグレーションファイルを作成し、以下のように変更します。

class EnableUuidExtension < ActiveRecord::Migration[6.0]
  def change
    enable_extension 'pgcrypto'
  end
end

その後migrateを実行。

$ rails db:migrate

== 20211216142135 EnableUuidExtension: migrating ==============================
-- enable_extension("pgcrypto")
   -> 0.0554s
== 20211216142135 EnableUuidExtension: migrated (0.0556s) =====================

これでマイグレーションファイル内でgen_random_uuid関数が使えるようになります。

テーブルの作成

試しにテーブルを作成して、uuid型のデフォルト値にgen_random_uuid関数を指定してみます。

class CreateTenants < ActiveRecord::Migration[6.0]
  def change
    create_table :tenants do |t|
      t.string :name, null: false
      t.uuid :uuid, null: false, default: 'gen_random_uuid()'

      t.timestamps
    end
  end
end

migrateコマンドを実行した後、schema.rbを確認してみます。

  create_table "tenants", force: :cascade do |t|
    t.string "name", null: false
    t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

default値にgen_random_uuid関数が指定できているようです。


動作確認

試しにtenantsテーブルにレコードを新規作成してみます。

$ Tenant.create!(name: 'その辺にいるWebエンジニア株式会社')
   (0.5ms)  BEGIN
  Tenant Exists? (4.5ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", nil], ["LIMIT", 1]]
  Tenant Create (3.7ms)  INSERT INTO "tenants" ("name", "uuid", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "その辺にいるWebエンジニア株式会社"], ["uuid", "b5188d11-d9a3-423b-be17-dae29800977e"], ["created_at", "2021-12-16 13:16:51.489288"], ["updated_at", "2021-12-16 13:16:51.489288"]]
   (3.8ms)  COMMIT

uuidにデフォルト値が挿入されていることがわかります。

念のためきちんとランダムな値になっているかどうかも確認してみます。

$ Tenant.create!(name: 'ランダム株式会社')
   (0.8ms)  BEGIN
  Tenant Exists? (7.4ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Tenant Exists? (4.4ms)  SELECT 1 AS one FROM "tenants" WHERE "tenants"."id" = $1 LIMIT $2  [["id", nil], ["LIMIT", 1]]
  Tenant Create (0.9ms)  INSERT INTO "tenants" ("name", "uuid", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "ランダム株式会社"], ["uuid", "a46244c4-8a3a-4b48-8236-1e76b475b4da"], ["created_at", "2021-12-16 13:19:26.898640"], ["updated_at", "2021-12-16 13:19:26.898640"]]
   (1.4ms)  COMMIT

先ほどと異なるuuidが生成されているので、ランダムな値になっているようです。


uuid_generate_v4じゃダメなの?

uuid_generate_v4という関数もあるんですが、PostgreSQLのドキュメントを見たところ、

注意: ランダムに生成された(バージョン4)UUIDのみが必要な場合には、代わりにpgcryptoモジュールのgen_random_uuid()を利用すること検討してください。

出典: www.postgresql.jp

という記述があったため、ランダムに生成された(バージョン4)UUIDのみが必要な場合にはpgcryptoモジュールを有効化するのがデファクトスタンダートのようです。


まとめ

Railsだとfriendly_id Gemを用いる際にuuidを設定することがあると思いますが、その場合にはpgcryptoモジュールを有効化するのを忘れないようにしましょう。(私は10分ハマりました)

devise-two-factorでの2要素認証設定時にdigestを変更したい

こんにちは!kossyです!




今回はdevise-two-factorでの2要素認証設定時にdigestを変更する方法について、ブログに残してみたいと思います。



環境

Ruby 2.6.9
Rails 6.0.4
MacOS BigSur
devise-two-factor 4.0.1



otpメソッドのオーバーライド

devise-two-factorは内部でrotpというGemを使っているのですが、ソースコードを見てみると、デフォルトでは暗号化にSHA1を用いています。

initializeでインスタンスを作成する際に、optionsでdigestを指定してやれば、SHA256等のSHA1以外のアルゴリズムも設定できるようになっていますので、

今回はotpメソッドをオーバーライドする形で実現したいと思います。

# devise-two-factor を使っているモデルと仮定

class User
  def otp(otp_secret = self.otp_secret)
    options = { digest: 'sha256' }
    ROTP::TOTP.new(otp_secret, options)
  end
end

これで、validate_and_consume_otp!メソッドが呼ばれた際に、digestにSHA256が設定されます。

github.com


動作確認

動作を確認してみます。確認のため、validate_and_consume_otp!メソッドをUserモデルに定義し、binding.pryを挟みます。

  def validate_and_consume_otp!(code, options = {})
    otp_secret = options[:otp_secret] || self.otp_secret
    return false unless code.present? && otp_secret.present?

    totp = otp(otp_secret)
    binding.pry
    if totp.verify(code.gsub(/\s+/, ""), drift_behind: self.class.otp_allowed_drift, drift_ahead: self.class.otp_allowed_drift)
      return consume_otp!
    end

    false
  end
$ totp
=> #<ROTP::TOTP:0x00007fa7328a1458 @digest="SHA256", @digits=6, @interval=30, @issuer=nil, @secret="35FZU6MQ3CAVSJWWN4VUAPVW">

digestにsha256が設定されていることが確認できました。

Rubyで五捨五超入を計算する

こんにちは!kossyです!





さて、今回はRubyで五捨五超入する方法について、備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.9




五捨五超入とは

主に薬価の計算を行う時に用いる端数処理で、薬価の円を点数に直して計算するときに使われます。

www.foresight.jp


Rubyで五捨五超入をやってみる

ロジックとしては、「小数点以下が0.5を超えていれば切り上げ」なので、

def rounding_up_five
  # レシーバの数字からレシーバをint型にして引き算
  decimal = self - to_i

  # 小数点以下の数値が0.5よりも大きいかどうか
  if decimal > 0.5
    to_i + 1
  else
    to_i
  end
end

のようになると思います。

早速実行してみましょう。説明のためにFloatクラスを拡張する形でメソッドを定義します。

$ irb

$ class Float
  def rounding_up_five
    # レシーバの数字からレシーバをint型にして引き算
    decimal = self - to_i

    # 小数点以下の数値が0.5よりも大きいかどうか
    if decimal > 0.5
      to_i + 1
    else
      to_i
    end
  end
end

$ 1.49.rounding_up_five
=> 1
$ 1.50.rounding_up_five
=> 1
$ 1.51.rounding_up_five
=> 2

小数点以下が0.5よりも大きい場合に、一の位に1を足した数値が返ることが確認できました。




勉強になりました。

graphql-rubyで認可制御を行うready?メソッドとauthorized?メソッド

こんにちは!kossyです!




さて、今回はgraphql-rubyで認可制御を行うready?メソッドとauthorized?メソッドについて、

備忘録としてブログに残してみたいと思います。




環境

Ruby 2.6.9
Rails 6.0.4
MacOS BigSur
graphql-ruby 1.13.0



graphql-rubyでの認可制御

graphql-rubyのドキュメントを見ると、Mutationの認可制御メソッドとして、ready?メソッドとauthorized?メソッドが使えると記載があります。

graphql-ruby.org

それぞれどのような違いがあるのでしょうか。ソースコードを見てみましょう。


ready?メソッドのソースコードを見る

まずはソースコードを読んでみます。

github.com

def ready?(**args)
  true
end

有無を言わさず true を返すメソッドになっていました。ちょっと意図がよくわからないので、コメントアウト部分を読んでみましょう。

Called before arguments are prepared.
Implement this hook to make checks before doing any work.

argumentsが準備される前に呼び出されます。
このフックを実装して、resolverが実行される前にチェックを行います。

If it returns a lazy object (like a promise), it will be synced by GraphQL
(but the resulting value won't be used).

遅延実行されるオブジェクト(Promiseのようなオブジェクトです)を返す場合、GraphQLによって同期されます。
(ただし、結果の値は使用されません)。

出典: graphql-ruby/lib/graphql/schema/resolver.rb at master · rmosolgo/graphql-ruby · GitHub

呼び出しのタイミングは、引数が準備される前とのことでした。


authorized?

こちらもソースコードを見てみます。

def authorized?(**inputs)
  self.class.arguments(context).each_value do |argument|
    arg_keyword = argument.keyword
    if inputs.key?(arg_keyword) && !(arg_value = inputs[arg_keyword]).nil? && (arg_value != argument.default_value)
      arg_auth, err = argument.authorized?(self, arg_value, context)
      if !arg_auth
        return arg_auth, err
      else
        true
      end
    else
      true
    end
  end
end

コメントアウト部分を見てみます。

Called after arguments are loaded, but before resolving.

引数がロードされた後、resolverの前に呼び出されます。

出典: graphql-ruby/lib/graphql/schema/resolver.rb at master · rmosolgo/graphql-ruby · GitHub

とのことでした。よしなにオーバーライドしてもOKなようです。

ready?とauthorized?のユースケース

ready?は引数がロードされる前に実行されるとのことなので、引数の値抜きで権限チェックを行いたい時に使うのがいいと思います。

def ready?
  if context[:current_user].admin?
    true
  else
     raise GraphQL::ExecutionError, "Only admins can run this mutation"
  end
end

逆にauthorized?メソッドは引数がロードされた後に実行されるとのことなので、引数の値込みで権限チェックを行いたい時に使うのがいいと思います。

例えば、管理者権限でも更新できない値があった場合に例外を発生させたりするパターンが考えられます。(履歴データとか)

def authorized?(**inputs)
  if context[:current_user].admin? && context[:current_user].can_edit_all?(inputs)
    true
  else
    raise GraphQL::ExecutionError, "Inculde Cannnot Edit Value"
  end
end


勉強になりました。

パスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみた

こんにちは!kossyです!




今回はパスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみたので、

備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4
MacOS BigSur



createアクション

まずはcreateアクションのソースコードから読んでみます。

github.com

  # POST /resource/password
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

resource_classは User のように、deviseを使って認証を行うモデルのクラスが返ります。

send_reset_password_instructionsはどんな処理でしょうか。

send_reset_password_instructions

# Attempt to find a user by its email. If a record is found, send new
# password instructions to it. If user is not found, returns a new user
# with an email not found error.
# Attributes must contain the user's email

# ↓Google翻訳先生に訳してもらいました。↓
# メールでユーザーを見つけようとします。 
# レコードが見つかった場合は、そのレコードに新しいパスワードの指示を送信します。 
# ユーザーが見つからない場合は、メールが見つかりませんというエラーで新しいユーザーを返します。
# 属性にはユーザーのメールアドレスが含まれている必要があります

def send_reset_password_instructions(attributes = {})
  recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
  recoverable.send_reset_password_instructions if recoverable.persisted?
  recoverable
end

コメントアウト部分で挙動について丁寧に説明されているので、内部で呼んでいるメソッドについては読まなくてもいいかなと思いつつ、

念のため目を通してみます。

find_or_initialize_with_errors

github.com

# Find or initialize a record with group of attributes based on a list of required attributes.
# 必要な属性のリストに基づいて、属性のグループを使用してレコードを検索または初期化します。

def find_or_initialize_with_errors(required_attributes, attributes, error = :invalid) #:nodoc:
  attributes.try(:permit!)
  attributes = attributes.to_h.with_indifferent_access
                          .slice(*required_attributes)
                          .delete_if { |key, value| value.blank? }

  if attributes.size == required_attributes.size
    record = find_first_by_auth_conditions(attributes) and return record
  end

  new(devise_parameter_filter.filter(attributes)).tap do |record|
    required_attributes.each do |key|
      record.errors.add(key, attributes[key].blank? ? :blank : error)
    end
  end
end

pry-byebugのstepメソッドで処理の内部に入って、適宜処理を実行して挙動の把握を試みます。

$ attributes = attributes.to_h.with_indifferent_access.slice(*required_attributes).delete_if { |key, value| value.blank? }
=> {"email"=>"sample@example.com"}

$ record = find_first_by_auth_conditions(attributes) and return record
=> nil

$   record = new(devise_parameter_filter.filter(attributes)).tap do |record|
    required_attributes.each do |key|
      record.errors.add(key, attributes[key].blank? ? :blank : error)
    end
  end

$ record
=> #<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil>

$ record.errors
=> #<ActiveModel::Errors:0x00007f8eec050488
 @base=#<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil>,
 @details={:email=>[{:error=>:not_found}]},
 @messages={:email=>["not found"]}>

attrを加工して必要なattrと数が一致していればレコードを引いてきて、適宜errorsオブジェクトにエラーを格納していました。

個人的には加工とエラー格納をprivateメソッドにしてメソッドの粒度を細かくしたいなと読んでて思いましたが余談ですね、、、

しかも12年前のコードが現役で動いていることに感動、、、またまた余談でした。

make User#send_reset_password_instructions to require all authenticat… · heartcombo/devise@850afec · GitHub

さて、recordを引いてくる部分のメソッドを読んでみます。


find_first_by_auth_conditions

github.com

def find_first_by_auth_conditions(tainted_conditions, opts = {})
  to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
end

こちらもstepメソッドで適宜実行してみます。

$ tainted_conditions
=> {"email"=>"sample@example.com"}

$ to_adapter
=> #<OrmAdapter::ActiveRecord:0x00007f8ee0094540
 @klass=
  User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime)>

$ devise_parameter_filter
=> #<Devise::ParameterFilter:0x00007f8ee011b810 @case_insensitive_keys=[:email], @strip_whitespace_keys=[:email]>

$  devise_parameter_filter.filter(tainted_conditions)
=> {"email"=>"sample@example.com"}

$ to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
=> nil

From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:22 OrmAdapter::ActiveRecord#find_first:

    21: def find_first(options = {})
 => 22:   construct_relation(klass, options).first
    23: end

$ klass
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime)

$ options
=> {"email"=>"sample@example.com"}

$  construct_relation(klass, options)
 User Load (5.0ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1  [["email", "sample@example.com"]]
=> []

From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:42 OrmAdapter::ActiveRecord#construct_relation:

    41: def construct_relation(relation, options)
    42:   conditions, order, limit, offset = extract_conditions!(options)
    43:
    44:   relation = relation.where(conditions_to_fields(conditions))
    45:   relation = relation.order(order_clause(order)) if order.any?
    46:   relation = relation.limit(limit) if limit
    47:   relation = relation.offset(offset) if offset
    48:
=>49:   relation
    50: end

$ relation
 User Load (5.0ms)  SELECT "users".* FROM "users" WHERE "users"."email" = $1  [["email", "sample@example.com"]]
=> []

最終的にdeviseのコードではないところのコードになりましたが、、、

find_first_by_auth_conditionsメソッドは引数でユーザーの情報を検索するメソッドでした。

find_or_initialize_with_errorsメソッドの処理もこれで一通り把握できました。


send_reset_password_instructions

ようやくself.send_reset_password_instructionsメソッドの2行目に突入です、、、

github.com

# Resets reset password token and send reset password instructions by email.
# Returns the token sent in the e-mail.
# リセットパスワードトークンをリセットし、パスワードのリセット手順を電子メールで送信します。 
# 電子メールで送信されたトークンが返り値になります。

def send_reset_password_instructions
  token = set_reset_password_token
  send_reset_password_instructions_notification(token)

  token
end

set_reset_password_token

github.com

def set_reset_password_token
  raw, enc = Devise.token_generator.generate(self.class, :reset_password_token)

  self.reset_password_token   = enc
  self.reset_password_sent_at = Time.now.utc
  save(validate: false)
  raw
end

encryptedされたtokenをreset_password_tokenとし、パスワードリセットメール送信日時を設定し、バリデーションを実行せずにレコードをDBに保存しています。

返り値は先ほど訳したコメントアウトにもある通り、トークン(encryptedされてない生の)が返っています。


send_reset_password_instructions_notification

github.com

def send_reset_password_instructions_notification(token)
  send_devise_notification(:reset_password_instructions, token, {})
end

ここからはstepメソッドで処理を追ってみます。

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/recoverable.rb:99 Devise::Models::Recoverable#send_reset_password_instructions_notification:

     98: def send_reset_password_instructions_notification(token)
 =>  99:   send_devise_notification(:reset_password_instructions, token, {})
    100: end

$ step

From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/authenticatable.rb:201 Devise::Models::Authenticatable#send_devise_notification:

    200: def send_devise_notification(notification, *args)
 => 201:   message = devise_mailer.send(notification, self, *args)
    202:   # Remove once we move to Rails 4.2+ only.
    203:   if message.respond_to?(:deliver_now)
    204:     message.deliver_now
    205:   else
    206:     message.deliver
    207:   end
    208: end

$ message = devise_mailer.send(notification, self, *args)
=> #<ActionMailer::MessageDelivery:0x2b0923acff60>

DeviseのMailerを呼ぶ処理を実行していました。以下のメソッドが実行されるようです。

def reset_password_instructions(record, token, opts = {})
  @token = token
  devise_mail(record, :reset_password_instructions, opts)
end

devise_mailの処理はこちら。

github.com

これでようやくsend_reset_password_instructionsメソッドも挙動の把握ができました、、、


successfully_sent?

controllerの処理に戻ってこれました、、、もう一度createアクションを記載します。

  # POST /resource/password
  def create
    self.resource = resource_class.send_reset_password_instructions(resource_params)
    yield resource if block_given?

    if successfully_sent?(resource)
      respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name))
    else
      respond_with(resource)
    end
  end

successfully_sent?というメソッド名からどんな処理か想像はつきますが、見に行ってみましょう。

github.com

# Helper for use after calling send_*_instructions methods on a resource.
# If we are in paranoid mode, we always act as if the resource was valid
# and instructions were sent.

# リソースでsend_ * _instructionsメソッドを呼び出した後に使用するヘルパー。 
# パラノイドモードの場合、リソースが有効であり、指示が送信されたかのように常に動作します。

def successfully_sent?(resource)
  notice = if Devise.paranoid
    resource.errors.clear
    :send_paranoid_instructions
  elsif resource.errors.empty?
    :send_instructions
  end

  if notice
    set_flash_message! :notice, notice
    true
  end
end

Devise.paranoidをどのように使うかは以下の記事が詳しかったです。

techracho.bpsinc.jp

noticeの値に応じて表示するフラッシュメッセージを変えているようでした。


まとめ

Devise.paranoidは知らない機能でした、、、コード読んでおいてよかった。

個人的には Time.now.utc も気になりました。アプリケーションのタイムゾーン運用によっては軽く問題になるような気もします。

recoverableモジュール入れるだけでパスワードリセットできるやん!viewもちゃちゃっとカスタマイズできるし!とか思ってましたが、

裏でいろんなことをdeviseがやってくれていたということを改めて認識する良い機会になりました。

devise-securityのsession_limitableモジュールのソースコードを追ってみた

こんにちは!kossyです!




さて、今回はdevise-securiyのsession_limitableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.8
Rails 6.0.4
MacOS BigSur
devise-security 0.16.1



前提

devise-securityのsession_limitableモジュールをincludeしたUserモデルが定義されているものとします。

Devise::Models::SessionLimitable

まずはモジュールのコードをさらっと確認してみます。

# 省略

module Devise
  module Models
    # SessionLimited ensures, that there is only one session usable per account at once.
    # If someone logs in, and some other is logging in with the same credentials,
    # the session from the first one is invalidated and not usable anymore.
    # The first one is redirected to the sign page with a message, telling that
    # someone used his credentials to sign in.
    module SessionLimitable
      extend ActiveSupport::Concern
      include Devise::Models::Compatibility

      # Update the unique_session_id on the model.  This will be checked in
      # the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}
      # @param unique_session_id [String]
      # @return [void]
      # @raise [Devise::Models::Compatibility::NotPersistedError] if record is unsaved
      def update_unique_session_id!(unique_session_id)
        raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

        update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
          Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
        end
      end

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end
    end
  end
end

SessionLimited...から始まる部分をGoogle翻訳で訳してみます。

SessionLimited ensures, that there is only one session usable per account at once.
If someone logs in, and some other is logging in with the same credentials,
the session from the first one is invalidated and not usable anymore.
The first one is redirected to the sign page with a message, telling that someone used his credentials to sign in.

SessionLimitedは、アカウントごとに一度に使用できるセッションが1つだけであることを保証します。
誰かがログインし、他の誰かが同じ資格情報でログインしている場合、
最初のセッションからのセッションは無効になり、使用できなくなります。
最初のセッションは、誰かが自分の資格情報を使用してサインインしたことを通知するメッセージとともにサインページにリダイレクトされます。

平たく言ってしまうと、多重セッションを禁ずるモジュールですね。

上記の機能はどのようにして実現しているのでしょうか。


update_unique_session_id!

Devise::Models::SessionLimitableモジュールには2つのメソッドが定義されていて、そのうちの一つがupdate_unique_session_id!メソッドです。

コメントアウト部分を訳してみます。

Update the unique_session_id on the model. This will be checked in
the Warden after_set_user hook in {file:devise-security/hooks/session_limitable}

モデル内のunique_session_idをupdateします。
これはWardenのafter_set_userフック内でチェックされます。(fileはdevise-security/hooks/session_limitable)

session_limitableを使うときに必要になる、unique_session_idをWardenのafter_set_userフック内でupdateするメソッドのようです。

  def update_unique_session_id!(unique_session_id)
    raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?

    update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
      Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    end
  end

このメソッドをUserモデルに定義し、間にbinding.pryを挟んだ上で、ログインを試みます。

From: /app/app/models/supervisor.rb:94 Supervisor#update_unique_session_id!:

    90: def update_unique_session_id!(unique_session_id)
    91:   binding.pry
    92:   raise Devise::Models::Compatibility::NotPersistedError, 'cannot update a new record' unless persisted?
    93:
 => 94:   update_attribute_without_validatons_or_callbacks(:unique_session_id, unique_session_id).tap do
    95:     Rails.logger.debug { "[devise-security][session_limitable] unique_session_id=#{unique_session_id}" }
    96:   end
    97: end

$ unique_session_id
=> nil

$ persisted?
=> true


update_attribute_without_validatons_or_callbacksメソッドを読んでみる必要がありそう。

update_attribute_without_validatons_or_callbacks

pry-byebugを導入していると使える、stepメソッドを使って update_attribute_without_validatons_or_callbacks の処理を見てみます。

From: /usr/local/bundle/gems/devise-security-0.16.0/lib/devise-security/models/compatibility/active_record_patch.rb:34 Devise::Models::Compatibility::ActiveRecordPatch#update_attribute_without_validatons_or_callbacks:

    33: def update_attribute_without_validatons_or_callbacks(name, value)
 => 34:   update_column(name, value)
    35: end

$ name
=> :unique_session_id

$ value
=> "buGqJFwGDV1bmXa39fgY"

ActiveRecordのupdate_columnメソッドを呼び出している処理でした。

update_columnメソッドでattributeの更新を行っているため、updated_at/updated_onの更新は行われません、、、

参考: 週刊Railsウォッチ(20181210)update_columnは要注意、DBカラムコメントは書こう、個人情報扱いの注意点、Capistranoはやっぱりいいほか|TechRacho by BPS株式会社


Warden::Manager.after_set_user

update_unique_session_id!メソッドの呼び出し元はWarden::Managerクラスのafter_set_userフックでした。

# After each sign in, update unique_session_id. This is only triggered when the
# user is explicitly set (with set_user) and on authentication. Retrieving the
# user from session (:fetch) does not trigger it.
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.devise_modules.include?(:session_limitable) &&
     warden.authenticated?(options[:scope]) &&
     !record.skip_session_limitable?

     if !options[:skip_session_limitable]
      unique_session_id = Devise.friendly_token
      warden.session(options[:scope])['unique_session_id'] = unique_session_id
      record.update_unique_session_id!(unique_session_id)
     else
      warden.session(options[:scope])['devise.skip_session_limitable'] = true
     end
  end
end

skip_session_limitableオプションがfalseでなければ、tokenを生成してwardenのsessionにunique_session_idを代入して、

.update_unique_session_id!メソッドを呼び出しています。

コメントアウト部分を訳してみます。

After each sign in, update unique_session_id.
This is only triggered when the user is explicitly set (with set_user) and on authentication.
Retrieving the user from session (:fetch) does not trigger it.

サインインするたびに、unique_session_idを更新します。
これは、ユーザーが(set_userを使用して)明示的に設定され、認証された場合にのみトリガーされます。
セッション(:fetch)からユーザーを取得しても、トリガーされません。

呼び出されるタイミングが記載されていました。

skip_session_limitable?

      # Should session_limitable be skipped for this instance?
      # @return [Boolean]
      # @return [false] by default. This can be overridden by application logic as necessary.
      def skip_session_limitable?
        false
      end

コメントアウト部分を意訳してみます。

Should session_limitable be skipped for this instance?
@return [Boolean]
@return [false] by default. This can be overridden by application logic as necessary.

session_limitableをスキップする必要があるかどうかを制御できます。
もしスキップしたい場合は必要に応じてアプリケーションロジックによって上書きできます。

trueを返すようにすれば、unique_session_idカラムの値を更新しなくなるようでした。

まとめ

update_columnを使っているため、timestamp機能が有効でもupdated_at/updated_on属性が更新されない問題があるみたいです、、、

自力で直せるか検討してみたんですが、Railsのtimestamp機能のprivateメソッドを直に呼び出すような案しか思いつきませんでした。

OSSコントリビュートのチャンスだと思うんですが、思ったよりも考慮事項が多そうに思いました。どこかで直したいけど。