💎

Rails 7.1 の generates_token_for を触ってみた感想

2023/10/31に公開

Leaner Technologies の @corocn です。Rails 7.1 で追加された generates_token_for メソッドを触ってみたので、感想を書きます。

リリースノート
https://edgeguides.rubyonrails.org/7_1_release_notes.html#add-activerecord-base-generates-token-for

Railsガイド
https://railsguides.jp/7_1_release_notes.html#activerecord-base-generates-token-forが追加

機能の概要

パスワードリセットで使うようなワンタイムトークンを生成することができる便利機能です。

対象のリソースから generates_token_for でトークン を生成し、find_by_token_for で トークンの署名を検証しつつ、データベースからレコードを取得することができます。

トークンの作成

class User < ActiveRecord::Base
  has_secure_password

  generates_token_for :password_reset, expires_in: 15.minutes do
    # Last 10 characters of password salt, which changes when password is updated:
    password_salt&.last(10)
  end
end

検証

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

データベースへのトークンの永続化は不要です。

geneates_token_for の返り値がトークンに含まれるようになっていて、検証時に生成時と同じ内容かどうかチェックされます。

ガイドに書かれているサンプルでは password_salt の一部をトークンに含める形にしており、パスワードリセットのタイミングで salt も再作成されるので、過去のトークンが無効になります。

生成されるトークンの中身

トークンの中身を見てみます。

user = User.first
token = user.generate_token_for(:password_reset)
=> "eyJfcmFpbHMiOnsiZGF0YSI6WzEsIkRZTEVDSHRxamUiXSwiZXhwIjoiMjAyMy0xMC0xNFQwNDo0MDoyNS40MjlaIiwicHVyIjoiVXNlclxucGFzc3dvcmRfcmVzZXRcbjkwMCJ9fQ==--abc5b95ff0404afa60a3db1b694e59e855213fe9"

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

{
  "_rails": {
    "data": [
      1, # User.id
      "DYLECHtqje" # saltの一部
    ],
    "exp": "2023-10-14T04:42:09.591Z", # 有効期限
    "pur": "User\npassword_reset\n900" # pur = purpose = トークンの用途
  }
}

eyJで始まるので JWT(JSON Web Token)かなと思いましたが、ただの Base64エンコードされた JSON でした。

pur属性の 900 は 有効期限の 15min x 60sec の 900 です。

署名検証どうやっているか

ActiveSupport::MessageVerifier を使っています。

https://api.rubyonrails.org/classes/ActiveSupport/MessageVerifier.html

署名は token の -- より後ろの部分になります。

署名部分

署名の検証に失敗すると InvalidSignature の例外が出力されます。

User.find_by_token_for!(:password_reset, invalid_token)
.../activerecord-7.1.1/lib/active_record/token_for.rb:101:in `find_by_token_for!': ActiveSupport::MessageVerifier::InvalidSignature (ActiveSupport::MessageVerifier::InvalidSignature)

実装コメントの翻訳

実装は ActiveRecord:: TokenFor にまとまっています。

https://api.rubyonrails.org/v7.1/classes/ActiveRecord/TokenFor/ClassMethods.html

Defines the behavior of tokens generated for a specific purpose. A token can be generated by calling TokenFor#generate_token_for on a record. Later, that record can be fetched by calling find_by_token_for (or find_by_token_for!) with the same purpose and token.

特定の目的のためのトークンを生成できる。
トークンはレコードに対して TokenFor#generate_token_for を呼び出すことで生成できる。
find_by_token_for (または find_by_token_for!) を呼び出すと、そのレコードをフェッチできる。

Tokens are signed so that they are tamper-proof. Thus they can be exposed to outside world as, for example, password reset tokens.

トークンは改ざんされないように署名されている ので、パスワード・リセット・トークンとして外部に公開することができる。

By default, tokens do not expire. They can be configured to expire by specifying a duration via the expires_in option. The duration becomes part of the token’s signature, so changing the value of expires_in will automatically invalidate previously generated tokens.

デフォルトでは、トークンの有効期限はない。
expires_in オプションで期間を指定することで、有効期限を設定することができる。

有効期間はトークンの署名の一部となるため、expires_inの値を変更すると、以前に生成されたトークンは自動的に無効になる。

A block may also be specified. When generating a token with TokenFor#generate_token_for, the block will be evaluated in the context of the record, and its return value will be embedded in the token as JSON.

ブロックを指定することもできる。
TokenFor#generate_token_forでトークンを生成するとき、ブロックはレコードのコンテキストで評価され、その戻り値はJSONとしてトークンに埋め込まれる。

Later, when fetching the record with find_by_token_for, the block will be evaluated again in the context of the fetched record. If the two JSON values do not match, the token will be treated as invalid. 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.

find_by_token_forでレコードをフェッチするときに、フェッチされたレコードのコンテキストでブロックが再度評価される。2つのJSON値が一致しない場合、トークンは無効として扱われる。

ブロックによって返される値は、人間が読めるプレーンテキストのJSONとしてトークンに埋め込まれるため、機密情報を含んでいないことに注意してね。

所感

  • Base64エンコードされた JSON をそのままトークンとして使うので、ユーザーサイドで中身が容易に見れてしまうは気になります。特にサンプルは salt の一部が露出することになります。
  • トークンの中身を完全に隠蔽したり永続化したり詳細にコントロールしたい場合は、MessageVerifier を使って実装してしまったほうが楽かもしれません。すでに自前で MessageVerifier を使っている場合は無理して載せ替える必要はなさそうです。
  • この機能が使われる場所を考えると、リリース後に機能修正いれるのもそれほど大変ではないので、ざっくり機能を作るのには便利だなと思いました。
リーナーテックブログ

Discussion