【Rails】ワンタイムトークンが作れる generates_token_for の内部実装を追ってみた

こんにちは、Plex Job 開発チームの池川です。

今回の記事では、Railsアプリでワンタイムトークンを使うにあたって ActiveRecord::Base.generates_token_forActiveRecord::Base.find_by_token_for ついて調べた内容をまとめていきます。

検証環境

どのようなメソッドか

generates_token_for は Rails7.1 から使えるようになったメソッドで、 Rails ガイド では下記のように説明されています。

ActiveRecord::Base.generates_token_forは特定の目的で利用するトークンの生成を定義します(#44189)。生成されたトークンは失効させることも、レコードデータを埋め込むこともできます。トークンを用いてレコードを取得すると、トークンのデータと現在のレコードのデータが比較されます。両者が一致しない場合、トークンは無効とみなされ、期限切れとして扱われます。

次に Rails ガイド に掲載されているサンプルコードを見ていきます。

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # `password_salt`(`has_secure_password`で定義される)は、
    # そのパスワードのsaltを返す。パスワードが変更されるとsaltも変更されるので、
    # パスワードが変更されるとこのトークンは無効になる。
    password_salt&.last(10)
  end
end

user = User.first
token = user.generate_token_for(:password_reset)
# => BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b

User.find_by_token_for(:password_reset, token)
# => #<User id: 1, .....>

user.update!(password: "new password")
User.find_by_token_for(:password_reset, token)
# => nil

generates_token_for では、次の3項目を設定します。

  • purpose
    • 何でもOKで、トークンが紐つく属性のようなもの
    • find_by_token_for の引数に指定して、トークンの検証時に使用する
  • expires_in
    • 有効期限
    • 有効期限が2時間後なら 2.hours 、2日後なら 2.days のように書く
  • block
    • トークンに含めるオプションを指定する

なお、expires_in と block は指定しなくても動作します。

expires_in を指定しない場合、有効期限がないため時間経過によるトークンの失効は行われません。block に指定したオプション、サンプルコードだとパスワードが変更されて password_salt が更新された場合にトークンが失効されます。

class User < ApplicationRecord
  generates_token_for :password_reset do
    password_salt&.last(10)
  end
end

block にオプションを指定しない場合、expires_in で定義された期間が終了した場合のみ、トークンが失効します。

class User < ApplicationRecord
  generates_token_for :password_reset, expires_in: 15.minutes
end

expires_in と block を両方指定しない場合、失効しないトークンが発行されます。

class User < ApplicationRecord
  generates_token_for :password_reset
end

find_by_token_for には、トークン生成時に指定した purpose と生成されたトークンをセットで渡します。 トークンが有効であれば該当のモデルオブジェクトが返りますが、トークンが無効の場合は nil が返ってきます。 上記のように、簡単にワンタイムトークンを生成、検証することが可能です。

次にサンプルコードを動かしていて気になった以下の3点について Rails の内部実装を見ながら順番に確認していきます。

  1. トークンはどのように生成されているか
  2. トークンは生成の都度変わるか
  3. トークンの検証時に、実際に比較されている値は何か

1. トークンはどのように生成されているか

generates_token_for の内部実装をみてみます。

generates_token_for の内部実装

github.com

generates_token_forActiveRecord::TokenFor に定義されています。 ActiveRecord::TokenFor には generates_token_for メソッドがクラスメソッドとインスタンスメソッドの2つ定義されています。 トークンの定義で使われるのはクラスメソッドの方です。 purpose に紐つける形で、purpose、expires_in、block をもとに TokenDefinition オブジェクトを作成しています。

def generates_token_for(purpose, expires_in: nil, &block)
  self.token_definitions = token_definitions.merge(purpose => TokenDefinition.new(self, purpose, expires_in, block))
end

トークンの定義をもとに、実際にトークンを生成するのはインスタンスメソッドの方です。 generate_token_for の中では generate_token が呼び出されており、その中でActiveSupport::MessageVerifier を使ってトークンを生成しています。

TokenDefinition = Struct.new(:defining_class, :purpose, :expires_in, :block) do # :nodoc:
  # 省略

    def generate_token(model)
      message_verifier.generate(payload_for(model), expires_in: expires_in, purpose: full_purpose)
    end
end

def generate_token_for(purpose)
  self.class.token_definitions.fetch(purpose).generate_token(self)
end

ActiveSupport::MessageVerifier

api.rubyonrails.org

message_verifier.generate の内部実装も覗いてみます。

# lib/acctive_support/message_verifier.rb
   def generate(value, **options)
      create_message(value, **options)
    end

    def create_message(value, **options) # :nodoc:
      sign_encoded(encode(serialize_with_metadata(value, **options)))
    end

    # 省略

    private
      def sign_encoded(encoded)
        digest = generate_digest(encoded)
        encoded << SEPARATOR << digest
      end

      # 省略

      def generate_digest(data)
        OpenSSL::HMAC.hexdigest(@digest, @secret, data)
      end

generate では、

の流れでトークンを生成します。

generate については message_vefirier.rb 内のコメントで下記のように説明されています。

Generates a signed message for the provided value. The message is signed with the +MessageVerifier+'s secret. Returns Base64-encoded message joined with the generated signature.

"指定された値に対して署名されたメッセージを生成します。 メッセージは +MessageVerifier+ の秘密鍵で署名されます。 生成された署名とともに、Base64エンコードされたメッセージを返します。"

署名時に使用される秘密鍵は、secret_key_base に指定した値が使われます。secret_key_basecredentials.enc.yml環境変数で指定していると思います。

verifier = ActiveSupport::MessageVerifier.new("secret")
verifier.generate("signed message")
# => "BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU--f67d5f27c3ee0b8483cebf2103757455e947493b"

トークンは下記の2つの値から構成されています。

  • BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU(エンコードされたメッセージ部分)
  • f67d5f27c3ee0b8483cebf2103757455e947493b(HMAC 署名部分)

エンコードされたメッセージ部分

purpose、expires_in、block からシリアライズされたハッシュを元に、Base64エンコードされた値が使われています。

# シリアライズされたハッシュ
{
  "_rails": {
    "data": [
      1, # Userのid
      "3gAd4RMK5" # ブロックで指定した値
    ],
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

# Base64エンコードされた値
BAhJIhNzaWduZWQgbWVzc2FnZQY6BkVU

HMAC署名

OpenSSL::HMAC.hexdigestm を使っています。

docs.ruby-lang.org

2. トークンは生成の都度変わるか

有効期限経過後に、トークンが変わるか試してみます。

user = User.first
token = user.generate_token_for(:password_reset)

# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNTo1NDowMS45NDlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--499c53d3345bf65e80eb843e99f55ba60d4592ba

# 有効期限15分経過後(expires_in: 15.minutes)
user = User.first
token = user.generate_token_for(:password_reset)

# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNjoyNzoyMC4yODlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--e9da25d31735b34179663371436f0588e286ea41

上記のように、有効期限後にトークンを再度生成したところ、変更されていました。 トークンの前半部分も似ていますがよく見ると違います💦 1で内部実装をみて確認したように、トークン生成には expires_in(有効期限)が使われています。 トークン生成時に使われる有効期限は下記のようにタイムスタンプ形式なので、generates_token_forトークンを生成する都度、有効期限の日時が毎回変わり、結果異なるトークンが生成されます。

"exp": "2024-09-30T05:58:44.104Z"

3. トークンの検証時に、実際に比較されている値は何か

github.com

find_by_token_for の内部実装を見ていきます。 find_by_token_forgenerates_token_for と同様に ActiveRecord::TokenFor に定義されています。 実際に比較しているのは resolve_tokenmodel && payload_for(model) == payload の部分です。

def resolve_token(token)
  payload = message_verifier.verified(token, purpose: full_purpose)
  model = yield(payload[0]) if payload
  model if model && payload_for(model) == payload # 👈ここ
end

payload_for(model) と payload には下記の値が入ります。

  • payload_for(model)
    • 現在のレコードのデータ
    • モデルのIDと、ブロックにオプションの指定があればその値を検証のタイミングで算出して配列に入れる
def payload_for(model)
  block ? [model.id, model.instance_eval(&block).as_json] : [model.id]
end

ActiveSupport::MessageVerifier

api.rubyonrails.org

message_verifier.verified の内部実装も覗いてみます。

# lib/acctive_support/message_verifier.rb
def verified(message, **options)
  catch_and_ignore :invalid_message_format do
    catch_and_raise :invalid_message_serialization do
      catch_and_ignore :invalid_message_content do
        read_message(message, **options)
      end
    end
  end
end

def read_message(message, **options) # :nodoc:
  deserialize_with_metadata(decode(extract_encoded(message)), **options)
end
# lib/acctive_support/message_verifier.rb
def extract_encoded(signed)
  if signed.nil? || !signed.valid_encoding?
    throw :invalid_message_format, "invalid message string"
  end

  if separator_index = separator_index_for(signed)
    encoded = signed[0, separator_index]
    digest = signed[separator_index + SEPARATOR_LENGTH, digest_length_in_hex]
  end

  unless digest_matches_data?(digest, encoded)
    throw :invalid_message_format, "mismatched digest"
  end

  encoded
end

def digest_matches_data?(digest, data)
  data.present? && digest.present? && ActiveSupport::SecurityUtils.secure_compare(digest, generate_digest(data))
end

extract_encoded では、

チェックしています。

Rubyvalid_encoding? を使ってエンコーディングが問題ないか判定し、問題があれば例外を投げます。

docs.ruby-lang.org

エンコーディングが問題なければ、トークンを前半のメッセージ部分と後半の署名部分に分けた上で、digest_matches_data? の中で、

  • メッセージ部分と署名部分がそれぞれ存在しているか?
  • メッセージ部分を元に再度署名を生成し、それがトークンに付与された署名と一致するか

の確認を行っています。

# lib/acctive_support/messages/metadata.rb
def deserialize_with_metadata(message, **expected_metadata)
  if dual_serialized_metadata_envelope_json?(message)
    envelope = deserialize_from_json(message)
    extracted = extract_from_metadata_envelope(envelope, **expected_metadata)
    deserialize_from_json_safe_string(extracted["message"])
  else
    deserialized = deserialize(message)
    if metadata_envelope?(deserialized)
      extract_from_metadata_envelope(deserialized, **expected_metadata)["data"]
    elsif expected_metadata.none? { |k, v| v }
      deserialized
    else
      throw :invalid_message_content, "missing metadata"
    end
  end
end

def extract_from_metadata_envelope(envelope, purpose: nil)
  hash = envelope["_rails"]

  if hash["exp"] && Time.now.utc >= parse_expiry(hash["exp"])
    throw :invalid_message_content, "expired"
  end

  if hash["pur"].to_s != purpose.to_s
    throw :invalid_message_content, "mismatched purpose"
  end

  hash
end

さらに deserialize_with_metadata の中で呼ばれている extract_from_metadata_envelope では、

  • 有効期限内かどうか
  • トークンの purpose が定義済みの値と一致しているか

を検証しています。

どのような値が入っているか確認してみる

1で動かしてみた Rails ガイドのサンプルコードを実行して、どのような値が入るか確認してみます。

payload_for(model)
# => [1, "3gAd4RMK5"]

payload
# => [1, "3gAd4RMK5"]

返ってきた値はトークン生成時に出てきたハッシュの中の data の部分です。

{
  "_rails": {
    "data": [
      1, # Userのid
      "3gAd4RMK5" # ブロックで指定した値
    ], 👈ここ
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

ということで実際に比較しているのは、「モデルオブジェクトのIDとブロックで指定したオプションの値が入った配列」でした。

利用にあたっての注意点

利用にあたっては以下のような注意点があります。

  1. ブロックに指定するオプションに機密情報を含まない
  2. モデルの属性値や属性値を構成する要素を指定する

1. ブロックに指定するオプションに機密情報を含まない

lib/active_record/token_for.rb 内のコメントでも下記ように記載されています。

Note that the value returned by the block should not contain sensitive information because it will be embedded in the token as human-readable plaintext JSON.

"なお、ブロックによって返される値は機密情報を含んではいけません。なぜならその値は、人間が読み取れるプレーンテキスト JSONとしてトークンに埋め込まれるためです。"

内部実装を見て確認した通り、トークンの前半部分はBase64エンコードしているため複合化できます。 試しに下記でブロックのオプションに名前と電話番号を指定してみました。

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # 氏名と電話番号をオプションに指定
    "氏名:#{name}-電話番号:#{phone_number}"
  end
end

user = User.first
token = user.generate_token_for(:password_reset)

payload = JSON.parse(Base64.decode64(token))
p JSON.pretty_generate(payload)

# =>
{
  "_rails": {
    "data": [
      1,
      "氏名:運送太郎-電話番号:99012345678" 👈ここ
    ], 
    "exp": "2024-09-30T05:58:44.104Z",
    "pur": "User\\npassword_reset\\n900"
  }
}

生成されたトークンを複合化したところ、名前と電話番号が表示されてしまっています。

オプションには機密情報などを指定しないようにしましょう!

2. モデルの属性値や属性値を構成する要素を指定する

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # 乱数を生成
    SecureRandom.hex(10)
  end
end

user = User.first
token = user.generate_token_for(:password_reset)
# => eyJfcmFpbHMiOnsiZGF0YSI6WzEsIjNnQWQ0Uk1LNS4iXSwiZXhwIjoiMjAyNC0wOS0zMFQwNTo1NDowMS45NDlaIiwicHVyIjoiQWRtaW5cbnBhc3N3b3JkX3Jlc2V0XG45MDAifX0=--499c53d3345bf65e80eb843e99f55ba60d4592ba

User.find_by_token_for(:password_reset, token)
# => nil

たとえば上記のように generates_token_for のブロックに乱数を指定した場合、find_by_token_fornil が返ります。 トークン生成時と検証時で、SecureRandom.hex(10) の結果が異なるためです。 (find_by_token_for では現在のレコードのデータとトークンのデータが同一か確認している)

User.find_by_token_for(:password_reset, token)

"payload_for(model): [1, \\"b8822aac56ac979f66bd\\"]" # 検証時のデータ
"payload: [1, \\"c9cff7d532f29cff6940\\"]" # トークン生成時のデータ

まとめ

今回の記事では、ActiveRecord::Base.generates_token_forActiveRecord::Base.find_by_token_for について、内部実装を確認しながら疑問点を解消していきました。 使うにあたっては一定の注意が必要ですが、数行書くだけでワンタイムトークンが生成できる便利なメソッドだと思います。

さいごに

最後に現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。 この記事を読んで、一緒に働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

dev.plex.co.jp