railsで開発途中からdocker環境を構築する場合の注意点

こんにちは!kossyです!





今回はアプリ開発環境に開発途中でdockerを用いた環境構築を行った際に軽くハマったことをブログに残してみたいと思います。





環境
Ruby 2.6.8
Rails 6.0.4.1
Docker-Compose 1.27.0



docker-compose build が通らない

以下のようなdockerfileおよびdocker-compose.ymlを書きました。

FROM ruby:2.6
ENV LANG C.UTF-8

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim graphviz

RUN mkdir /app
WORKDIR /app

COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock

RUN bundle install
COPY . /app
version: '3'
services:
  db:
    image: postgres:13.4
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: password
    ports:
      - "2345:2345"
    volumes:
      - ./tmp/db:/var/lib/postgresql/data
  web:
    build: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/app
      - gem_data:/usr/local/bundle
    ports:
      - 3000:3000
    depends_on:
      - db
    tty: true
    stdin_open: true
volumes:
  gem_data:

コンソールで docker-compose build を実行すると、以下のエラーログが出力されました。

/usr/local/lib/ruby/2.6.0/rubygems.rb:283:in `find_spec_for_exe': Could not find 'bundler' (2.1.4) required by your /app/Gemfile.lock. (Gem::GemNotFoundException)
To update to the latest version installed on your system, run `bundle update --bundler`.
To install the missing version, run `gem install bundler:2.1.4`
	from /usr/local/lib/ruby/2.6.0/rubygems.rb:302:in `activate_bin_path'
	from /usr/local/bin/bundle:23:in `<main>'

どうやらGemfile.lockのbundlerのバージョンが見つからないと言われているようだったので、Gemfile.lockを一度空にし、再度 docker-compose build を実行したところ、buildが通りました。

皆さんも開発環境に途中からdockerを導入する場合はお気をつけください。

環境変数を管理できるGem「figaro」を使ってみる

こんにちは!kossyです!



今回は環境変数を管理できるGem「figaro」を個人開発アプリに導入してみたので、備忘録としてブログに残してみたいと思います。




偉大なる本家レポジトリ

github.com



環境
Ruby 2.6.8
Rails 6.0.4.1
docker-compose 1.27.0



導入

まずはGemfileを編集してbundle。

# Gemfile

gem 'figaro'

# terminal

$ bundle

次に、以下のコマンドを実行します。

# terminal

$ bundle exec figaro install

      create  config/application.yml
      append  .gitignore

config配下にapplication.ymlというファイルが追加され、.gitigoreに当該ファイルが追加されました。

# config/application.yml

# Add configuration values here, as shown below.
#
# pusher_app_id: "2954"
# pusher_key: 7381a978f7dd7f9a1117
# pusher_secret: abdc3b896a0ffb85d373
# stripe_api_key: sk_test_2J0l093xOyW72XUYJHE4Dv2r
# stripe_publishable_key: pk_test_ro9jV5SNwGb1yYlQfzG17LHK
#
# production:
#   stripe_api_key: sk_live_EeHnL644i6zo4Iyq4v1KdV9H
#   stripe_publishable_key: pk_live_9lcthxpSIHbGwmdO941O1XVU

使い方

application.ymlで定義した値は、ENVオブジェクトで参照できます。

# config/application.yml

sendgrid_user_name: api_key
sendgrid_api_key: AA.AAaaAaaaAA1aaa1a_AAAAaA.aaaAAAaAaAa1aa1AaAa1AaAaaaaAAAAaaaAaAAAaAAa
sendgrid_domain: kossy-web-engineer@example.com

# terminal

$ rails c

>  ENV["sendgrid_user_name"]
=> "api_key"

> ENV["sendgrid_api_key"]
=> "AA.AAaaAaaaAA1aaa1a_AAAAaA.aaaAAAaAaAa1aa1AaAa1AaAaaaaAAAAaaaAaAAAaAAa"

> ENV["sendgrid_domain"]
=> "kossy-web-engineer@example.com"

ENVオブジェクトのkeyを指定することで値を取得することができました。

rails c は特にオプションを付与しないとデフォルトでdevelop環境で立ち上がるので、試しにtest環境で起動してみます。

# config/application.yml

development:
  sendgrid_user_name: api_key
  sendgrid_api_key: AA.AAaaAaaaAA1aaa1a_AAAAaA.aaaAAAaAaAa1aa1AaAa1AaAaaaaAAAAaaaAaAAAaAAa
  sendgrid_domain: kossy-web-engineer@example.com

test:
  sendgrid_user_name: test_api_key # 変更
  sendgrid_api_key: AA.AAaaAaaaAA1aaa1a_AAAAaA.aaaAAAaAaAa1aa1AaAa1AaAaaaaAAAAaaaAaAAAaAAa
  sendgrid_domain: kossy-web-engineer@example.com

# terminal

$ RAILS_ENV=test rails c

>  Rails.env
=> "test"

> ENV["sendgrid_user_name"]
=> "test_api_key"

test環境の場合はtest環境の値が返却されるのが確認できました。

応用的な使い方(その1)

githubのReadMeを読むと、Figaro.envでも値を参照できるようなので、試してみました。

# terminal

$ rails c

> Figaro.env.sendgrid_user_name
=> "api_key"

ENVオブジェクトを使わなくても環境変数の値を参照できました。

ただ、figaroではなく別の環境変数管理機能に移行するとなった場合に、修正箇所が増えてしまうので、個人的にはこの機能は使わないと思います。。。

応用的な使い方(その2)

config/figaro.rb で以下のように定義すると、application.ymlで定義されてないkeyがあった場合にアプリ起動時に例外を発生させることができます。

# config/initializers/figaro.rb

Figaro.require_keys("sendgrid_user_name", "sendgrid_api_key", "sendgrid_domain", "sendgrid_config") # sendgrid_configというkeyは未定義

# terminal

$ rails c

/usr/local/bundle/gems/figaro-1.2.0/lib/figaro.rb:28:in `require_keys': Missing required configuration keys: ["sendgrid_config"] (Figaro::MissingKeys)
	from /app/config/initializers/figaro.rb:1:in `<main>'

Figaro::MissingKeysという例外がthrowされてコンソールの立ち上げに失敗しました。


応用的な使い方(その3)

これはfigaro独自の使い方ではなくymlの話になりますが、以下のように書くことで共通化したい値をまとめて設定することができます。

# config/application.yml

default: &default
  sendgrid_domain: kossy-web-enginner

development:
  <<: *default

test:
  <<: *default

# terminal

$ rails c

> ENV["sendgrid_domain"]
=> "kossy-web-enginner"

> exit

$ RAILS_ENV=test rails c

> ENV["sendgrid_domain"]
=> "kossy-web-enginner"

まとめ

環境変数の管理をするGemはdotenvがありますが、dotenvを本番環境で使わない方がいいのではという意見もあります。

Dotenvはproductionで使わないほうがよいのではという話の続き - なんかかきたい

figaroの場合はunicornの再起動時に読み直されるとのことなので、余計なトラブルに見舞われずに済みそうです。

参考にさせていただいたサイト

素晴らしいコンテンツの提供、誠にありがとうございます。

Dotenvはproductionで使わないほうがよいのではという話の続き - なんかかきたい

RailsのActionDispatch::Http::URLのsubdomainメソッドのソースコードを覗いてみる

こんにちは!kossyです!




今回は、ActionDispatch::Http::URLのsubdomainメソッドのソースコードを読む機会があったので、備忘録としてブログに残してみたいと思います。



環境

Ruby 2.6.6
Rails 6.1.0
MacOS BigSur



subdomainメソッド

ソースコードの位置はこちらです。

github.com

      # Returns all the \subdomains as a string, so <tt>"dev.www"</tt> would be
      # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>,
      # such as 2 to catch <tt>"www"</tt> instead of <tt>"www.rubyonrails"</tt>
      # in "www.rubyonrails.co.uk".
      def subdomain(tld_length = @@tld_length)
        ActionDispatch::Http::URL.extract_subdomain(host, tld_length)
      end

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

Returns all the \subdomains as a string, so "dev.www" would be returned for "dev.www.rubyonrails.org". You can specify a different tld_length, such as 2 to catch "www" instead of "www.rubyonrails" in "www.rubyonrails.co.uk".


すべての\ subdomainsを文字列として返すため、「dev.www.rubyonrails.org」に対して "dev.www" が返されます。 「www.rubyonrails.co.uk」の中で、「www.rubyonrails」の代わりに「www」をキャッチするために、2などの別の tld_length を指定できます。

出典: https://github.com/rails/rails/blob/6-1-stable/actionpack/lib/action_dispatch/http/url.rb#L341

引数でキャッチするサブドメインの階層を指定できるようです。

例えば、https://www.dev.sample.comがRequestのurlの場合、

$ request.subdomain
=> "www.dev"

$ request.subdomain(2)
=> "www"

$ request.subdomain(3)
=> ""

の返り値を得られます。(存在しないサブドメインの階層が指定されても例外上がらないのか、、、)

実際にサブドメインを算出する処理は、ActionDispatch::Http::URL.extract_subdomainで行っているようなので、見に行ってみます。

# Returns the subdomains of a host as a String given the domain level.
#
#    # Top-level domain example
#    extract_subdomain('www.example.com', 1) # => "www"
#    # Second-level domain example
#    extract_subdomain('dev.www.example.co.uk', 2) # => "dev.www"
def extract_subdomain(host, tld_length)
  extract_subdomains(host, tld_length).join(".")
end

# Returns the subdomains of a host as an Array given the domain level.
#
#    # Top-level domain example
#    extract_subdomains('www.example.com', 1) # => ["www"]
#    # Second-level domain example
#    extract_subdomains('dev.www.example.co.uk', 2) # => ["dev", "www"]
def extract_subdomains(host, tld_length)
  if named_host?(host)
    extract_subdomains_from(host, tld_length)
  else
    []
  end
end

# 実際にサブドメインを算出する処理
def extract_subdomains_from(host, tld_length)
  parts = host.split(".")
  parts[0..-(tld_length + 2)]
end

extract_subdomains_fromのコードをコンソールから試してみましょう。

# 適当なコントローラーでbinding.pryで処理を止める

$ host = request.host
=> "www.dev.sample.com"

$ parts = host.split(".")
=> ["www", "dev", "sample", "com"]

$ tld_length = 1

$ parts[0..-(tld_length + 2)]
=> ["www", "dev"]

$ tld_length = 2

$ parts[0..-(tld_length + 2)]
=> ["www"]

tld_lengthの数値によって取得できるサブドメインの数が変化するカラクリが解明できましたね。

subdomainsメソッド

ついでにsubdomainsメソッドも読んでみます。

      # Returns all the \subdomains as an array, so <tt>["dev", "www"]</tt> would be
      # returned for "dev.www.rubyonrails.org". You can specify a different <tt>tld_length</tt>,
      # such as 2 to catch <tt>["www"]</tt> instead of <tt>["www", "rubyonrails"]</tt>
      # in "www.rubyonrails.co.uk".
      def subdomains(tld_length = @@tld_length)
        ActionDispatch::Http::URL.extract_subdomains(host, tld_length)
      end

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

Returns all the \subdomains as an array, so ["dev", "www"] would be returned for "dev.www.rubyonrails.org". You can specify a different tld_length, such as 2 to catch ["www"] instead of ["www", "rubyonrails"] in "www.rubyonrails.co.uk".

すべての\ subdomainsを配列として返すため、「dev.www.rubyonrails.org」に対して ["dev"、 "www"] が返されます。 ["www"、 "rubyonrails"] の代わりに ["www"] をキャッチするために2などの別の tld_length を指定できます。 「www.rubyonrails.co.uk」。


出典: https://github.com/rails/rails/blob/6-1-stable/actionpack/lib/action_dispatch/http/url.rb#L333

こちらもコンソールで試してみます。

$ request.subdomains
=> ["www", "dev"]

$ request.subdomains(1)
=> ["www", "dev"]

$ request.subdomains(2)
=> ["www"]

サブドメインが配列で返ることがわかりました。


@tld_lengthが追加された経緯

取得するサブドメインの階層を指定できるtld_lengthですが、どういった経緯で追加されたのでしょうか。

該当のコミットはこちらでした。

github.com

Pull Requestは私の調査力不足で見つけられなかったのですが、1で固定だったのをconfigファイルで設定できるように修正したようです。

ちなみに、tldは「Top Level Domain」の略のようです。

guides.rubyonrails.org


まとめ

多階層のサブドメインを設定する運用の場合は、configファイルのtld_lengthの値をいじくる必要があるようです。

techracho.bpsinc.jp

Railsソースコードを読んでいると、愚直な実装にお目にかかれて良きです。

大いに参考にさせていただいた記事

素晴らしいコンテンツの提供、誠にありがとうございます。

https://techracho.bpsinc.jp/baba/2012_11_19/6393

ログイン周りの情報の追跡を実現する、deviseの「trackable」のソースコードを追ってみる

こんにちは!kossyです!



今回は、ログイン周りの追跡を実現する、deviseの「trackable」のソースコードを追ってみたので、ブログに残してみたいと思います。




環境

Ruby 2.6系
Rails 6.0.4
devise 4.8.0




github.com




trackableモジュールとは

ソースコード内のコメントアウト部分を訳してみます。

Track information about your user sign in. It tracks the following columns:

sign_in_count - Increased every time a sign in is made (by form, openid, oauth)
current_sign_in_at - A timestamp updated when the user signs in
last_sign_in_at - Holds the timestamp of the previous sign in
current_sign_in_ip - The remote ip updated when the user sign in
last_sign_in_ip - Holds the remote ip of the previous sign in


ユーザーのサインインに関する情報を追跡します。次の列を追跡します。

sign_in_count: サインインが行われるたびに増加します(form、openid、oauthによる)
current_sign_in_at: ユーザーがサインインしたときに更新されるタイムスタンプ
last_sign_in_at: 前のサインインのタイムスタンプを保持します
current_sign_in_ip: ユーザーがサインインするとリモートIPが更新されます
last_sign_in_ip: 前のサインインのリモートIPを保持します

出典: https://github.com/heartcombo/devise/blob/master/lib/devise/models/trackable.rb

trackableモジュールを導入することで何ができるようになるのかが一通りわかりました。

次の項で詳しくコードの中身を追ってみます。

required_fields

def self.required_fields(klass)
  [:current_sign_in_at, :current_sign_in_ip, :last_sign_in_at, :last_sign_in_ip, :sign_in_count]
end

trackableモジュールをincludeしているモデルに、配列内のsymbolと同名のメソッドが定義されているかを検証するために使うメソッドかと思われます。

このメソッドの参照箇所はこちらです。

devise/models.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

def self.check_fields!(klass)
  failed_attributes = []
  instance = klass.new

  klass.devise_modules.each do |mod|
    constant = const_get(mod.to_s.classify)

    constant.required_fields(klass).each do |field|
      failed_attributes << field unless instance.respond_to?(field)
    end
  end

  if failed_attributes.any?
    fail Devise::Models::MissingAttribute.new(failed_attributes)
  end
end

instance.respond_to?(field)でメソッドの定義確認を行なっていました。

update_tracked_fields

def update_tracked_fields(request)
  old_current, new_current = self.current_sign_in_at, Time.now.utc
  self.last_sign_in_at     = old_current || new_current
  self.current_sign_in_at  = new_current

  old_current, new_current = self.current_sign_in_ip, extract_ip_from(request)
  self.last_sign_in_ip     = old_current || new_current
  self.current_sign_in_ip  = new_current

  self.sign_in_count ||= 0
  self.sign_in_count += 1
end

current_sign_in_atの値をold_currentとし、現在時刻をnew_currentとし、old_currentがあればそちらをlast_sign_in_atの値として採用して、なければnew_currentの値を採用しています。

そして、current_sign_in_atの値にnew_currentを代入しています。

extract_ip_fromメソッドは覗いてみる必要がありそう。

devise/trackable.rb at master · heartcombo/devise · GitHub

def extract_ip_from(request)
  request.remote_ip
end

requestオブジェクトのリモートIPを返すメソッドでした。

remote_ipメソッドの定義元はこちら(本ブログでの説明範囲を超えているのでソースコードだけ明記します)

rails/request.rb at 6-1-stable · rails/rails · GitHub

last_sign_in_ipの処理はlast_sign_in_atと似通ったものになっています。

残りの2行は、sign_in_countがnilの場合、0を代入しています。

最後にsign_in_countの値を1増加させています。


update_tracked_fields!

devise/trackable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

def update_tracked_fields!(request)
  # We have to check if the user is already persisted before running
  # `save` here because invalid users can be saved if we don't.
  # See https://github.com/heartcombo/devise/issues/4673 for more details.
  return if new_record?

  update_tracked_fields(request)
  save(validate: false)
end

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

We have to check if the user is already persisted before running
`save` here because invalid users can be saved if we don't.
See https://github.com/heartcombo/devise/issues/4673 for more details.

実行する前に、ユーザーがすでに永続化されているかどうかを確認する必要があります
ここで save するのは、無効なユーザーを保存できるからです。
詳細については、https://github.com/heartcombo/devise/issues/4673を参照してください。

出典: https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/trackable.rb#L33

この変更のPRはこちらですね。

github.com

余談ですが、「どうやってテストすればいいかわからない」というコメントに対して、「統合テストを作成し、検証が実行された場合にクラスにグローバル値を設定する検証をモデルに追加すればいいよ」とアドバイスをしているのが大変参考になります。。。

update_tracked_fields!はどこから呼ばれているんでしょう。

devise/trackable.rb at c82e4cf47b02002b2fd7ca31d441cf1043fc634c · heartcombo/devise · GitHub

# frozen_string_literal: true

# After each sign in, update sign in time, sign in count and sign in IP.
# 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.respond_to?(:update_tracked_fields!) && warden.authenticated?(options[:scope]) && !warden.request.env['devise.skip_trackable']
    record.update_tracked_fields!(warden.request)
  end
end

ここでした。ログイン後のhookで呼ばれているみたいです。


まとめ

カラムを用意してtrackableモジュールをincludeするだけで機能を追加できるので、とても便利かと思います。

また、deviseの-ble系のモジュールの中でも、内部のコードがかなり少ないモジュールでした。

加えて、update_tracked_fieldsとupdate_tracked_fields!のように、「値の代入」と「値の保存」を分けるようにコーディングしているのも、設計として面白いなと思いました。

コード量的にもOSSのコードリーディングの入り口に向いているのではないかと思います。

PostgreSQLのdate_truncの使い方とユースケース

こんにちは!kossyです!




今回はRailsPostgreSQLを使ってSQLをベタ書きする際に使えるdate_truncのユースケースについてブログに残してみたいと思います。




環境
PostgreSQL 12系



公式ドキュメントを読む

まずは公式Docを読んでみます。

www.postgresql.jp

date_trunc関数は概念的に数値に対するtrunc関数と類似しています。

date_trunc(field, source [, time_zone ])

sourceは、データ型timestamp、timestamp with time zoneもしくはintervalの評価式です。
(date型とtime型の値はそれぞれ自動的にtimestampもしくはintervalにキャストされます。)
fieldは、入力値の値をどの精度で切り捨てるかを選択します。
同様に戻り値はtimestamp、timestamp with time zoneもしくはinterval型で、
指定した精度より下のすべてのフィールドがゼロに設定(日と月については1に設定)されます。

入力値がtimestamp with time zone型の値なら、特定の時間帯を考慮して切り捨てが行われます。
たとえば、日を切り捨てると値はその時間帯での真夜中になります。
デフォルトでは切り捨ては現在のTimeZoneの設定に従いますが、別の時間帯を指定することができるようにオプションのtime_zone引数が提供されています。

timestamp without time zoneあるいはintervalの入力を処理している間は時間帯は指定できません。 これらは額面通りの値で扱われます。

出典:
https://www.postgresql.jp/document/12/html/functions-datetime.html

日付に対する加工処理を行うことができる関数です。次の項で使用されるシチュエーションについて記述します。


シチュエーション

例えばRailsのApplicationRecordを継承したモデルのcreated_atを比較演算子にそのまま渡しても、
JSTUTCが時刻に含まれてしまって、正しく比較できないことがあります。

そんな時、date_truncを使えばうまく比較することができます。

self.created_at < date_trunc('day', self.started_at) + interval '1 day'

このように、date_trunc関数を使うことで、RailsActiveRecordでは記述できない痒いところにも手が届く実装が可能になります。



勉強になりました。

Railsで一ヶ月間の日付の配列を作る

こんにちは!kossyです!




ここ最近土日に時間が作れずブログの更新が滞ってしまいました、、、(現在も時間作りにくい状況が続いています)
暇を見つけてTIPS的なことを細々とブログに残そうかと思います。

今回は、Railsで一ヶ月間の日付の配列を作ってみたので、備忘録として残してみたいと思います。



環境

Ruby 2.6.8



コード

全晒しです。

today = Date.today

(today.beginning_of_month..today.end_of_month).to_a
=> [
 Sun, 01 Aug 2021,
 Mon, 02 Aug 2021,
 Tue, 03 Aug 2021,
 Wed, 04 Aug 2021,
 Thu, 05 Aug 2021,
 Fri, 06 Aug 2021,
 Sat, 07 Aug 2021,
 Sun, 08 Aug 2021,
 Mon, 09 Aug 2021,
 Tue, 10 Aug 2021,
 ...
]

月初と月末のRangeオブジェクトを作成して、to_aメソッドを実行してやると、日付の配列として展開できます。



勉強になりました。

trace_location gem を使ってみる

こんにちは!kossyです!




さて、今回はOSSソースコードを読む際に役に立つtrace_locationを使ってみたので、ブログに残してみたいと思います。



試す

まずは公式ドキュメントを参考にして進めます。

# Gemfile

gem 'trace_location'
$ bundle

以下、rails cで試しました。

$ config = Rails.application.config.database_configuration[Rails.env]
 {"adapter"=>"postgresql",
 "encoding"=>"unicode",
 "pool"=>5,
 "username"=>"root",
 "password"=>"password",
 "host"=>"db",
 "database"=>"sample_api_development"}

$ TraceLocation.trace do
  # You just surround you want to track the process.
  ActiveRecord::Base.establish_connection(config)
end

Created at /app/log/trace_location-2021081506081629010190.md

=> true

特に指定しなければMarkdown拡張子でlog配下に出力されます。

オプション名 内容
format :md, :log, :csv (default: :md) :md
match Regexp, Symbol, String or Array for allow list [:activerecord, :activesupport]
ignore Regexp, Symbol, String or Array for deny list /bootsnap activesupport/

約2500行のマークダウンが出力されたので詳細は割愛します、、、

establish_connetctionメソッドの処理から出力がされていました。

# ActiveRecord::ConnectionHandling.establish_connection

def establish_connection(config_or_env = nil)
  config_hash = resolve_config_for_connection(config_or_env)
  connection_handler.establish_connection(config_hash)
end

# called from (pry):4
# /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/connection_handling.rb:162

deviseのvalid_password?メソッドをtraceしてみる

こちらは300行ほどだったため、全て貼り付けてみます。

Generated by [trace_location](https://github.com/yhirano55/trace_location) at 2021-08-15 07:21:57 +0000

<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:71</summary>

##### Devise::Models::DatabaseAuthenticatable#valid_password?

```ruby
def valid_password?(password)
  Devise::Encryptor.compare(self.class, encrypted_password, password)
end

# called from (pry):12
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:15</summary>

##### User::GeneratedAttributeMethods#encrypted_password

```ruby
def #{temp_method_name}
  name = #{attr_name_expr}
  _read_attribute(name) { |n| missing_attribute(n, caller) }
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:72
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:37</summary>

##### ActiveRecord::AttributeMethods::Read#_read_attribute

```ruby
def _read_attribute(attr_name, &block) # :nodoc
  sync_with_transaction_state if @transaction_state&.finalized?
  @attributes.fetch_value(attr_name.to_s, &block)
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:40</summary>

##### ActiveModel::AttributeSet#fetch_value

```ruby
def fetch_value(name, &block)
  self[name].value(&block)
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/attribute_methods/read.rb:39
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:15</summary>

##### ActiveModel::AttributeSet#[]

```ruby
def [](name)
  attributes[name] || Attribute.null(name)
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:41
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set/builder.rb:38</summary>

##### ActiveModel::LazyAttributeHash#[]

```ruby
def [](key)
  delegate_hash[key] || assign_default_value(key)
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:16
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute.rb:40</summary>

##### ActiveModel::Attribute#value

```ruby
def value
  # `defined?` is cheaper than `||=` when we get back falsy values
  @value = type_cast(value_before_type_cast) unless defined?(@value)
  @value
end

# called from /usr/local/bundle/gems/activemodel-6.0.3.7/lib/active_model/attribute_set.rb:41
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:14</summary>

##### Devise::Encryptor.compare

```ruby
def self.compare(klass, hashed_password, password)
  return false if hashed_password.blank?
  bcrypt   = ::BCrypt::Password.new(hashed_password)
  if klass.pepper.present?
    password = "#{password}#{klass.pepper}"
  end
  password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
  Devise.secure_compare(password, hashed_password)
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/database_authenticatable.rb:72
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:15
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:55</summary>

##### BCrypt::Password#initialize

```ruby
def initialize(raw_hash)
  if valid_hash?(raw_hash)
    self.replace(raw_hash)
    @version, @cost, @salt, @checksum = split_hash(self)
  else
    raise Errors::InvalidHash.new("invalid hash")
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:16
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:73</summary>

##### BCrypt::Password#valid_hash?

```ruby
def valid_hash?(h)
  self.class.valid_hash?(h)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:56
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:49</summary>

##### BCrypt::Password.valid_hash?

```ruby
def valid_hash?(h)
  /^\$[0-9a-z]{2}\$[0-9]{2}\$[A-Za-z0-9\.\/]{53}$/ === h
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:74
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:81</summary>

##### BCrypt::Password#split_hash

```ruby
def split_hash(h)
  _, v, c, mash = h.split('$')
  return v.to_str, c.to_i, h[0, 29].to_str, mash[-31, 31].to_str
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/password.rb:58
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:37</summary>

##### Devise::Models::DatabaseAuthenticatable::ClassMethods.pepper

```ruby
def #{accessor}
  if defined?(@#{accessor})
    @#{accessor}
  elsif superclass.respond_to?(:#{accessor})
    superclass.#{accessor}
  else
    Devise.#{accessor}
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:6</summary>

##### ActiveRecord::DynamicMatchers.respond_to_missing?

```ruby
def respond_to_missing?(name, _)
  if self == Base
    super
  else
    match = Method.match(self, name)
    match && match.valid? || super
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:40
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:32</summary>

##### ActiveRecord::DynamicMatchers::Method.match

```ruby
def match(model, name)
  klass = matchers.find { |k| k.pattern.match?(name) }
  klass.new(model, name) if klass
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:10
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:37</summary>

##### ActiveRecord::DynamicMatchers::Method.pattern

```ruby
def pattern
  @pattern ||= /\A#{prefix}_([_a-zA-Z]\w*)#{suffix}\Z/
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:33
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:37</summary>

##### ActiveRecord::DynamicMatchers::Method.pattern

```ruby
def pattern
  @pattern ||= /\A#{prefix}_([_a-zA-Z]\w*)#{suffix}\Z/
end

# called from /usr/local/bundle/gems/activerecord-6.0.3.7/lib/active_record/dynamic_matchers.rb:33
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/module/attribute_accessors.rb:57</summary>

##### Devise.pepper

```ruby
def self.#{sym}
  @@#{sym}
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/models.rb:43
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:25</summary>

##### Object#present?

```ruby
def present?
  !blank?
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:17
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:56</summary>

##### NilClass#blank?

```ruby
def blank?
  true
end

# called from /usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:26
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:47</summary>

##### BCrypt::Engine.hash_secret

```ruby
def self.hash_secret(secret, salt, _ = nil)
  if valid_secret?(secret)
    if valid_salt?(salt)
      if RUBY_PLATFORM == "java"
        Java.bcrypt_jruby.BCrypt.hashpw(secret.to_s.to_java_bytes, salt.to_s)
      else
        __bc_crypt(secret.to_s, salt)
      end
    else
      raise Errors::InvalidSalt.new("invalid salt")
    end
  else
    raise Errors::InvalidSecret.new("invalid secret")
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:20
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:87</summary>

##### BCrypt::Engine.valid_secret?

```ruby
def self.valid_secret?(secret)
  secret.respond_to?(:to_s)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:48
```
</details>
<details open>
<summary>/usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:82</summary>

##### BCrypt::Engine.valid_salt?

```ruby
def self.valid_salt?(salt)
  !!(salt =~ /^\$[0-9a-z]{2,}\$[0-9]{2,}\$[A-Za-z0-9\.\/]{22,}$/)
end

# called from /usr/local/bundle/gems/bcrypt-3.1.16/lib/bcrypt/engine.rb:49
```
</details>
<details open>
<summary>/usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:500</summary>

##### Devise.secure_compare

```ruby
def self.secure_compare(a, b)
  return false if a.blank? || b.blank? || a.bytesize != b.bytesize
  l = a.unpack "C#{a.bytesize}"

  res = 0
  b.each_byte { |byte| res |= byte ^ l.shift }
  res == 0
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise/encryptor.rb:21
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:501
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/object/blank.rb:121</summary>

##### String#blank?

```ruby
def blank?
  # The regexp that matches blank strings is expensive. For the case of empty
  # strings we can speed up this method (~3.5x) with an empty? call. The
  # penalty for the rest of strings is marginal.
  empty? ||
    begin
      BLANK_RE.match?(self)
    rescue Encoding::CompatibilityError
      ENCODED_BLANKS[self.encoding].match?(self)
    end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:501
```
</details>
<details open>
<summary>/usr/local/bundle/gems/activesupport-6.0.3.7/lib/active_support/core_ext/numeric/conversions.rb:105</summary>

##### ActiveSupport::NumericWithFormat#to_s

```ruby
def to_s(format = nil, options = nil)
  case format
  when nil
    super()
  when Integer, String
    super(format)
  when :phone
    ActiveSupport::NumberHelper.number_to_phone(self, options || {})
  when :currency
    ActiveSupport::NumberHelper.number_to_currency(self, options || {})
  when :percentage
    ActiveSupport::NumberHelper.number_to_percentage(self, options || {})
  when :delimited
    ActiveSupport::NumberHelper.number_to_delimited(self, options || {})
  when :rounded
    ActiveSupport::NumberHelper.number_to_rounded(self, options || {})
  when :human
    ActiveSupport::NumberHelper.number_to_human(self, options || {})
  when :human_size
    ActiveSupport::NumberHelper.number_to_human_size(self, options || {})
  when Symbol
    super()
  else
    super(format)
  end
end

# called from /usr/local/bundle/gems/devise-4.8.0/lib/devise.rb:502
```
</details>

valid_password?メソッドは、引数に与えられたpasswordが正しいかどうかを検証するメソッドですが、 true か false かを判定するまでに多岐に渡るメソッドが呼び出されていることがわかります。

このGemがあれば、OSSのコードリーディングが捗りそうですね。

Rails APIモードでomniauth導入時に「OmniAuth::NoSessionError (You must provide a session to use OmniAuth.)」が出る場合の対処

こんにちは!kossyです!




さて、今回はRails APIモードでomniauth導入時に「OmniAuth::NoSessionError (You must provide a session to use OmniAuth.)」が出る場合の対処法をブログに残してみたいと思います。




環境

Ruby 2.6.8
Rails 6.0.4
devise_token_auth 1.1.5
omniauth 2.0.4



ActionDispatchミドルウェアを導入する

RailsAPIモードの場合、セッションやcookie等のミドルウェアがデフォルトでインストールされません。

omniauthを使う場合、セッションのミドルウェアは必須になるため、ActionDispatchミドルウェアを導入する必要があります。

application.rbを以下のように編集してください。

module SampleApi
  class Application < Rails::Application
    config.load_defaults 6.0
    config.api_only = true

    # ↓追加↓
    # For Omniauth
    config.session_store :cookie_store, key: '_interslice_session'
    config.middleware.use ActionDispatch::Cookies # Required for all session management
    config.middleware.use ActionDispatch::Session::CookieStore, config.session_options
    # ↑ここまで↑
end

omniauthのエラー定義を見てみる

OmniAuth::NoSessionErrorの処理の中身を見に行ってみます。

omniauth/strategy.rb at master · omniauth/omniauth · GitHub

    # The logic for dispatching any additional actions that need
    # to be taken. For instance, calling the request phase if
    # the request path is recognized.
    #
    # @param env [Hash] The Rack environment.
    def call!(env) # rubocop:disable CyclomaticComplexity, PerceivedComplexity
      unless env['rack.session']
        error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
        raise(error)
      end

      @env = env

      warn_if_using_get_on_request_path

      @env['omniauth.strategy'] = self if on_auth_path?

      return mock_call!(env) if OmniAuth.config.test_mode

      begin
        return options_call if on_auth_path? && options_request?
        return request_call if on_request_path? && OmniAuth.config.allowed_request_methods.include?(request.request_method.downcase.to_sym)
        return callback_call if on_callback_path?
        return other_phase if respond_to?(:other_phase)
      rescue StandardError => e
        raise e if env.delete('omniauth.error.app')

        return fail!(e.message, e)
      end

      @app.call(env)
    end

重要なのはこの箇所ですね。

unless env['rack.session']
  error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
  raise(error)
end

env['rack.session'] がfalseの場合、OmniAuth::NoSessionErrorをraiseしています。

まとめ

そもそも env['rack.session'] ってなんやねんな状態なので、近々 ActionDispatch周りのミドルウェアも含めて、コードリーディング記事を書くと思います。

TypeScriptの外部ライブラリの型チェックが通らない場合の対処法

こんにちは!kossyです!




さて、今回はTypeScriptの外部ライブラリの型チェックが通らない場合の対処法についてブログに残してみたいと思います。



環境

Vue.js 3系
TypeScript 3.9.7



skiplibcheckをtrueにする

TypeScriptの対応が甘い外部ライブラリを導入していると、型チェックが通らないことがあります。

その場合、tsconfig.jsonのskiplibcheckをtrueにすることで、*.d.tsファイルの型チェックをskipすることができます。

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

skiplibcheckはdefaultではfalseになっていますが、明示的にtrueにすることで、上述の挙動を実現することができます。

とはいえ、型チェックの恩恵が受けられなくなるというデメリットもありますので、よく考えてオプションを設定することをお勧めします。

参考にさせていただいたサイト

素晴らしいコンテンツの作成ありがとうございます。

https://t-yng.jp/post/skiplibcheck
https://youtu.be/V7wIYhdNc30

アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみる

こんにちは!kossyです!




今回は、アカウントロック機能を実現する、deviseのlockableのソースコードを追ってみたので、備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.8
Rails 6.0.4
devise 4.8.0



なお、コードの説明の前提として、既にdeviseが導入済みで、deviseを利用しているUserというモデルが定義されていることとします。


github.com




deviseのlockableとは?

アカウントロック機能を提供するdeviseのモジュールの一つです。これだけだと説明としてあまりにも淡白なので、ソースコードコメントアウト部分を意訳してみます。

Handles blocking a user access after a certain number of attempts.
Lockable accepts two different strategies to unlock a user after it's blocked: email and time. The former will send an email to the user when the lock happens, containing a link to unlock its account.
The second will unlock the user automatically after some configured time (ie 2.hours).
It's also possible to set up lockable to use both email and time strategies.

特定の回数ログインに失敗したユーザーのアクセスをブロックします。
Lockableは、ブロックされたユーザーのアカウントロックを解除するために、電子メールと時間という2つの異なる戦略を受け入れます。

前者は、ロックが発生したときに、アカウントのロックを解除するためのリンクを含む電子メールをユーザーに送信します。
2つ目は、設定された時間(つまり、2時間)後にユーザーのロックを自動的に解除します。

電子メールと時間の両方の戦略を使用するようにロック可能を設定することも可能です。

出典: devise/lockable.rb at master · heartcombo/devise · GitHub

「特定の回数ログインに失敗したユーザーのアクセスをブロックする機能」
「ロック解除にはメールのリンクを時間内に踏む必要がある」

この2つを抑えておけばひとまずOKでしょう。

lockableを使うには

locableを有効にするには、deviseを利用するモデルでlockableをincludeすることと、いくつかのカラムをモデルに追加する必要があります。

class User < ApplicationRecord
  devise :database_authenticatable, :lockable
end
class AddTrackableColumnsToUsers < ActiveRecord::Migration[6.0]
  def change
    change_table :users do |t|
      # Lockableに必要なカラム
      t.integer  :failed_attempts, default: 0, null: false
      t.string   :unlock_token
      t.datetime :locked_at
    end

    add_index :users, :unlock_token, unique: true
  end
end

これで準備OKです!


configの確認

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

Lockable adds the following options to 「devise」

「maximum_attempts」how many attempts should be accepted before blocking the user.


「lock_strategy」 lock the user account by :failed_attempts or :none.


「unlock_strategy」 unlock the user account by :time, :email, :both or :none.


「unlock_in」 the time you want to lock the user after to lock happens. Only available when unlock_strategy is :time or :both.


「unlock_keys」 is the keys you want to use when locking and unlocking an account.


Lockableは、「devise」に次のオプションを追加します


「maximum_attempts」ユーザーをブロックする前に受け入れる必要のある試行回数。


「lock_strategy」は、:failed_attemptsまたは:noneでユーザーアカウントをロックします。


「unlock_strategy」は、:time、:email、:both、または:noneでユーザーアカウントのロックを解除します。


「unlock_in」は、ロックした後にユーザーをロックしたい時間が発生します。 Unlock_strategyが:timeまたは:bothの場合にのみ使用できます。


「unlock_keys」は、アカウントをロックおよびロック解除するときに使用するキーです。

出典: devise/lockable.rb at master · heartcombo/devise · GitHub

lockableを導入することでクラスにいくつかメソッドが生えるようなので、一つずつコンソールで実行して確認してみます。

maximum_attempts

$ User.maximum_attempts
=> 20

maximum_attemptsの値は、config/initializers/devise.rb で変更することができます。

config/initializers/devise.rb

# Number of authentication tries before locking an account if lock_strategy
# is failed attempts.
# config.maximum_attempts = 20

# 訳: lock_strategyが失敗した場合に、アカウントをロックするまでの認証試行回数

デフォルトでは20で、アプリケーションの要件に応じて変更が可能です。


lock_strategy

$ User.lock_strategy
=> :failed_attempts

こちらもconfig/initializers/devise.rbで変更が可能です。

# config/initializers/devise.rb

# Defines which strategy will be used to lock an account.
# :failed_attempts = Locks an account after a number of failed attempts to sign in.
# :none            = No lock strategy. You should handle locking by yourself.
# config.lock_strategy = :failed_attempts

# 訳: アカウントをロックするために使用される戦略を定義します。

# :failed_attempts = ログインに何度も失敗した後、アカウントをロックします。
# :none = ロック戦略なし。 ロックは自分で処理する必要があります。

デフォルトではfailed_attemptsが設定されています。


unlock_strategy

$ User.unlock_strategy
=> :both

こちらもconfig/initializers/devise.rbで変更が可能です。

# config/initializers/devise.rb

# Defines which strategy will be used to unlock an account.
# :email = Sends an unlock link to the user email
# :time  = Re-enables login after a certain amount of time (see :unlock_in below)
# :both  = Enables both strategies
# :none  = No unlock strategy. You should handle unlocking by yourself.
# config.unlock_strategy = :both

# 訳:  アカウントのロックを解除するために使用する戦略を定義します。
# :email =ユーザーの電子メールにロック解除リンクを送信します
# :time =一定時間後にログインを再度有効にします(以下の:unlock_inを参照)
# :both =両方の戦略を有効にします
# :none =ロック解除戦略はありません。 ロック解除は自分で行う必要があります。

デフォルトではbothが設定されています。

unlock_in

$ User.unlock_in
=> 1 hour

$ User.unlock_in.class
=> ActiveSupport::Duration

config/initializers/devise.rbで変更可

# config/initializers/devise.rb

# Time interval to unlock the account if :time is enabled as unlock_strategy.
# config.unlock_in = 1.hour

# 訳: :timeがunlock_strategyとして有効になっている場合に、アカウントのロックを解除する時間間隔。

デフォルトでは1時間で設定されています。


unlock_keys

$  User.unlock_keys
=> [:email]
# config/initializers/devise.rb

# Defines which key will be used when locking and unlocking an account
# config.unlock_keys = [:email]

# 訳: アカウントをロックおよびロック解除するときに使用するキーを定義します。

デフォルトでは:emailというSymbolが入った配列が設定されています。

ソースコードを追ってみる


valid_for_authentication?メソッドが処理の起点っぽいので、まずはこのメソッドから読んでみます。

valid_for_authentication?

# Overwrites valid_for_authentication? from Devise::Models::Authenticatable
# for verifying whether a user is allowed to sign in or not. If the user
# is locked, it should never be allowed.

# 訳: Devise :: Models :: Authenticationからvalid_for_authentication? を上書きして、ユーザーがサインインを許可されているかどうかを確認します。ユーザーがロックされている場合、ログインは許可されるべきではありません。

def valid_for_authentication?
  return super unless persisted? && lock_strategy_enabled?(:failed_attempts)

  # Unlock the user if the lock is expired, no matter
  # if the user can login or not (wrong password, etc)
  unlock_access! if lock_expired?

  if super && !access_locked?
    true
  else
    increment_failed_attempts
    if attempts_exceeded?
      lock_access! unless access_locked?
    else
      save(validate: false)
    end
    false
  end
end

なるほど、元のメソッドをオーバーライドしたメソッドだったんですね。

一行ずつ読み解いて行きます。

return super unless persisted? && lock_strategy_enabled?(:failed_attempts)

selfが保存されておらず、failed_attemptsがenabledになっていない場合、オーバーライド元のメソッドを実行します。

# Unlock the user if the lock is expired, no matter
# if the user can login or not (wrong password, etc)

# 訳: ユーザーがログインできるかどうか(パスワードが間違っているなど)に関係なく、 ロックの有効期限が切れている場合は、ユーザーのロックを解除します。

unlock_access! if lock_expired?

コメントアウト部分を見ればどんな処理なのか想像が付きますが、unlock_access!メソッドとlock_expired?メソッドを見に行ってみましょう。

unlock_access!

# Unlock a user by cleaning locked_at and failed_attempts.

# 訳: locked_atとfailed_attemptsをクリーニングして、ユーザーのロックを解除します。

def unlock_access!
  self.locked_at = nil
  self.failed_attempts = 0 if respond_to?(:failed_attempts=)
  self.unlock_token = nil  if respond_to?(:unlock_token=)
  save(validate: false)
end

locked_atをnilに、failed_attemptsカラムが定義されていればfailed_attemptsを0に、unlock_tokenカラムが定義されていればunlock_tokenをnilにし、
バリデーションをスキップしてレコードを保存していました。

lock_expired?

# Tells if the lock is expired if :time unlock strategy is active

# 訳:  timeロックストラテジーが有効な場合にロックが期限切れかどうかを返します。

def lock_expired?
  if unlock_strategy_enabled?(:time)
    locked_at && locked_at < self.class.unlock_in.ago
  else
    false
  end
end

:timeがunlock strategyとして有効になっている場合、locked_atが存在し、unlock_inの設定値を見に行きながら、ロックが期限切れかどうかを返すメソッドでした。

access_locked?

if super && !access_locked?
  true
else
  increment_failed_attempts
  if attempts_exceeded?
    lock_access! unless access_locked?
  else
    save(validate: false)
  end
  false
end

オーバーライド元のメソッドを呼び出して true だった場合 かつ、access_locked?の結果がfalseだった場合に、trueを返し、そうでない場合、failed_attemptsの値を更新し、failed_attemptsが一定の回数を超えていた場合、アカウントをロックしています。(既にロックされている場合を除く)

failed_attemptsが一定の回数を超えていなかった場合は、バリデーションをskipしつつレコードを保存しています。

まずはaccess_locked?メソッドを見てみましょう。

# Verifies whether a user is locked or not.

# 訳: ユーザーがロックされているかどうかを確認します。

def access_locked?
  !!locked_at && !lock_expired?
end

locked_atがnilかつ先ほど見たlock_expired?がfalseの場合、trueを返すメソッドになっていました。

increment_failed_attempts

次にincrement_failed_attemptsメソッドを見てみます。

def increment_failed_attempts
  self.class.increment_counter(:failed_attempts, id)
  reload
end

メソッド名の通り、failed_attemptsの値を1増加させるメソッドでした。

attempts_exceeded?

次にattempts_exceeded?メソッドを見てみます。

def attempts_exceeded?
  self.failed_attempts >= self.class.maximum_attempts
end

selfのfailed_attemptsが、deviseを利用しているモデルのmaximum_attempts以上だった場合にtrueを返すメソッドでした。

lock_access!

# Lock a user setting its locked_at to actual time.
# * +opts+: Hash options if you don't want to send email
#   when you lock access, you could pass the next hash
#   `{ send_instructions: false } as option`.

# 訳: ユーザーのlocked_atを実際の時間に設定してユーザーをロックします。
# opts: ハッシュオプションアクセスをロックしたときにメールを送信したくない場合は、 `{send_instructions:false}をオプション`として渡すことができます。

def lock_access!(opts = { })
  self.locked_at = Time.now.utc

  if unlock_strategy_enabled?(:email) && opts.fetch(:send_instructions, true)
    send_unlock_instructions
  else
    save(validate: false)
  end
end

これで一通り valid_for_authentication? メソッドは読めました。

その他のメソッド

lockable.rbには他にもメソッドが定義されていたので、集中力が続く限り見ていきます。

reset_failed_attempts!

# Resets failed attempts counter to 0.

# 訳:  failed_attemptsを0にリセットします。

def reset_failed_attempts!
  if respond_to?(:failed_attempts) && !failed_attempts.to_i.zero?
    self.failed_attempts = 0
    save(validate: false)
  end
end

failed_attemptsカラムが定義されていて、値が0でなければ、failed_attemptsカラムの値を0にし、バリデーションをスキップしてレコードを保存しています。

ログイン時のhooksが呼び出し元のようです。

# devise/lib/devise/hooks/lockable.rb

# frozen_string_literal: true

# After each sign in, if resource responds to failed_attempts, sets it to 0
# This is only triggered when the user is explicitly set (with set_user)
Warden::Manager.after_set_user except: :fetch do |record, warden, options|
  if record.respond_to?(:reset_failed_attempts!) && warden.authenticated?(options[:scope])
    record.reset_failed_attempts!
  end
end

send_unlock_instructions

# Send unlock instructions by email
def send_unlock_instructions
  raw, enc = Devise.token_generator.generate(self.class, :unlock_token)
  self.unlock_token = enc
  save(validate: false)
  send_devise_notification(:unlock_instructions, raw, {})
  raw
end

unlock_token向けのtokenを生成して、レコードを保存しつつメールを送信するメソッドになっています。

send_devise_notification

def send_devise_notification(notification, *args)
  message = devise_mailer.send(notification, self, *args)
  # Remove once we move to Rails 4.2+ only.
  if message.respond_to?(:deliver_now)
    message.deliver_now
  else
    message.deliver
  end
end

ここが実際にメール送信を呼び出すメソッドですね。


疲れたのでここまでにします、、、

まとめ

lockableはvalid_for_authentication?をオーバーライドして、アカウントがロックされているかの確認や、認証に失敗した場合に失敗回数をカウントし、アカウントロックをかけたりしているということは最低限理解しておきたい。