今回達成すること
JWT(以下、トークン)の発行とデコードを行う、AuthToken
クラスを作成します。
このクラスはインスタンスが生成された時(newされた時)
- 引数にトークンがあった場合はトークンをデコードし、
- トークンが無い場合はトークンを発行します。
クラスファイルの置き場所
まず、Railsの「app」ディレクトリの直下に「services(サービィシーズ)」ディレクトリを作成します。
「services」ディレクトリの中は、サービス単位でディレクトリを作成します。
今回は認証サービスを行う、「user_auth」ディレクトリを作成し、認証に使用するファイル一式を管理します。
ディレクトリ構造
api/app
├── services
│ └── user_auth
│ └── 認証に関わるファイルを管理
...
AuthTokenのクラスファイルを作成する
「services/user_auth」ディレクトリと、AuthToken
クラスを扱う
root $ mkdir -p api/app/services/user_auth && touch $_/auth_token.rb
UserAuthを継承したクラスを宣言する
gem jwt
を読み込み、前回作成したUserAuth
モジュールを継承したクラスを宣言します。
api/app/services/user_auth/auth_token.rb
require 'jwt'
module UserAuth
class AuthToken
end
end
Zeitwerkの仕様に合ったクラスの宣言を
Rails6から導入されたオートロードシステムの「Zeitwerk(ツァイトベルク)」の仕様で、ファイルパスと一致するクラスを宣言しないと正しく読み込まれません。
つまり、
- 「servicers」以下のファイルパス =>「user_auth/auth_token.rb」と
- クラスの宣言 =>
UserAuth::AuthToken
を
一致させる必要があります。
何も継承しないクラスを宣言する場合は、
- 「services」ディレクトリ直下に
auth_token.rb を作成し、 - その中で
AuthToken
クラスを宣言すればOKです。
初期化時のアクションを作成する
AuthToken
クラスのインスタンスが生成された時(newされた時)のアクションを作成しましょう。
api/app/services/user_auth/auth_token.rb
require 'jwt'
module UserAuth
class AuthToken
# 追加
attr_reader :token
attr_reader :payload
attr_reader :lifetime
def initialize(lifetime: nil, payload: {}, token: nil, options: {})
if token.present?
@payload, _ = JWT.decode(token.to_s, decode_key, true, decode_options.merge(options))
@token = token
else
@lifetime = lifetime || UserAuth.token_lifetime
@payload = claims.merge(payload)
@token = JWT.encode(@payload, secret_key, algorithm, header_fields)
end
end
# ここまで
end
end
-
attr_reader
... 読み取り専用のアクセサ。インスタンス生成時に値を指定し、以後読み取り専用となります。
主に、インスタンス変数の値を変えたくない場合に使用します。
-
initialize
... インスタンス生成時には4つの引数を持ちます。-
lifetime
... トークンの有効期限を指定します。デフォルトは2週間。 -
payload
... トークンに埋め込む情報をハッシュで指定します。 -
token
... トークンを指定します。 -
options
... トークンの追加オプションを指定します。デフォルトオプションは下記URLをご覧ください。
-
-
_
...JWT.decode
を実行すると、payload
とヘッダーが入った配列が返されます。=>
[ {<payload>}, {<header>} ]
1番目のpayloadはインスタンス変数に代入し、2番目のヘッダーはアンダーバーに代入されます。
Rubyではアンダーバーも変数名として扱うので、
_
にはヘッダーの値が入っています。ちなみに、アンダーバーを指定しない、
@payload, = JWT.decode
の書き方でも同じ動きをします。参考 Is it good practice having local variables starting with underscore? - StackOverflow
エンコード・デコード時の初期値を作成する
プライベートメソッドに、トークンエンコードとデコード時の初期値を作成します。
キーを追加する
デコードキーが無い場合はエンコードキーを指定するようにしています。
api/app/services/user_auth/auth_token.rb(以下、省略)
require 'jwt'
module UserAuth
class AuthToken
...
# 以下、追加
private
# エンコードキー(config/initializers/user_auth.rb)
def secret_key
UserAuth.token_secret_signature_key.call
end
# デコードキー(config/initializers/user_auth.rb)
def decode_key
UserAuth.token_public_key || secret_key
end
end
end
アルゴリズムを追加する
...
# デコードキー(config/initializers/user_auth.rb)
def decode_key
UserAuth.token_public_key || secret_key
end
# 以下、追加
# アルゴリズム(config/initializers/user_auth.rb)
def algorithm
UserAuth.token_signature_algorithm
end
end
end
オーディエンスを追加する
verify_audience?
は、user_auth.rb のtoken_audience
に値がある場合にtrue
を返します。token_audience
は、その値を返します。
...
# アルゴリズム(config/initializers/user_auth.rb)
def algorithm
UserAuth.token_signature_algorithm
end
# 以下、追加
# オーディエンスの値がある場合にtrueを返す
def verify_audience?
UserAuth.token_audience.present?
end
# オーディエンス(config/initializers/user_auth.rb)
def token_audience
verify_audience? && UserAuth.token_audience.call
end
end
end
トークン有効期限を秒数に変換する
インスタンス変数の@lifetime
には、
- 有効期限の指定があった場合はその値を、
- 指定がない場合はデフォルトの2週間を代入します。
from_now
は、有効期限経過後の日時を返し、to_i
で秒数に変換しています。
...
# オーディエンス(config/initializers/user_auth.rb)
def token_audience
verify_audience? && UserAuth.token_audience.call
end
# 以下、追加
# トークン有効期限を秒数で返す
def token_lifetime
@lifetime.from_now.to_i
end
end
end
デコード時のオプションを指定する
decode_options
は、デコード時のデフォルトオプションの値を返します。
オプションのverify_aud
がtrue
の場合、
- エンコード時の
aud
の値と - デコード時の
aud
の値が
一致していないとエラーとなり、無効なトークンと判断されます。
...
# トークン有効期限を秒数で返す
def token_lifetime
@lifetime.from_now.to_i
end
# 以下、追加
# デコード時オプション
# default: https://www.rubydoc.info/github/jwt/ruby-jwt/master/JWT/DefaultOptions
def decode_options
{
aud: token_audience,
verify_aud: verify_audience?,
algorithm: algorithm
}
end
end
end
この実装は、payloadにオーディエンスの値が含まれることを担保するだけで、セキュリティを高くするものではありません。
当初、リクエスト先のURLをRailsで取得し、payloadのオーディエンスに含める実装を考えましたが、トークンは誰でも偽造できるため意味のないセキュリティだと判断しました。
JWTのセキュリティは、署名鍵と有効期限をどう実装するかで決まります。
クレームを指定する
payloadに含める値をクレームと言います。
クレームには、デフォルトで
- 有効期限(expiration/エクスパレイション)と、
- 受信者(audience/オーディエンス)の値を含めています。
追加クレームは、インスタンス生成時に引数で指定します。
...
# デコード時オプション
# default: https://www.rubydoc.info/github/jwt/ruby-jwt/master/JWT/DefaultOptions
def decode_options
{
aud: token_audience,
verify_aud: verify_audience?,
algorithm: algorithm
}
end
# 以下、追加
# デフォルトクレーム
def claims
_claims = {}
_claims[:exp] = token_lifetime
_claims[:aud] = token_audience if verify_audience?
_claims
end
end
end
-
_claims
... アンダーバーから始まる変数は、ローカル変数であることを明示的にする書き方です。必須のルールではありません。
参考 stackoverrun
エンコード時のヘッダーオプションを指定する
typ
は、ヘッダーオプションの追加方法を説明するだけのもので、無くても大丈夫です。
通常、オブジェクトが JWTであることが既に分かっている場合には、アプリケーションではこの値を使用しません。
引用: JSON Web Token
...
# デフォルトクレーム
def claims
_claims = {}
_claims[:exp] = token_lifetime
_claims[:aud] = token_audience if verify_audience?
_claims
end
# 以下、追加
# エンコード時のヘッダー
# Doc: https://openid-foundation-japan.github.io/draft-ietf-oauth-json-web-token-11.ja.html#typHdrDef
def header_fields
{ typ: "JWT" }
end
end
end
以上で準備が整いました。
実際にコンソールで確認してみましょう。
JWTを発行する
Railsコンソールに入ります。
root $ docker-compose run --rm api rails c
JWTを発行するには、AUthToken
クラスのインスタンスを生成します。
> token = UserAuth::AuthToken.new.token
> token
=>"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDEyMTIyNTIsImF1ZCI6ImxvY2FsaG9zdDo4MDgwIn0.Vy7RCxQ5hyicZ6GekTgUUKuaKLG5p5Rzm8Sg6_-zRDA"
JWTが発行できました。
コピーしてjwt.ioのデバッガーにペーストしてみましょう。
ヘッダーの
typ
は「JWT」
payloadの
exp
は2週間、aud
は「http://localhost:3000」aud
は「localhost:8080」
になっていることを確認してください。
実装が上手くいっている証拠です。
2021年10月05日 更新
audienceの値は「http://localhost:3000」が正しい値となります。
下記記事、
【Rails×JWT】ログイン認証解説とJWT初期設定ファイルの作成
目次「JWTの初期設定ファイルを作成する」の2021年06月09日 修正情報がこちらの記事に反映されておりませんでした。申し訳ありません。
audienceの値はJWTの受け手を指定します。今回はRailsがJWTを受けるので、Railsサーバの URLを指定しています。
クレームを追加する
payloadのクレームを追加する場合は、インスタンス生成時にハッシュを渡します。
> token = UserAuth::AuthToken.new(payload: {sub: 1}).token
JWTをデコードする
デコードするには、インスタンス生成時の引数にJWTを渡します。
> UserAuth::AuthToken.new(token: token)
payloadを確認してみましょう。
> UserAuth::AuthToken.new(token: token).payload
=> {"exp"=>1601257279, "aud"=>"http://localhost:3000", "sub"=>1}
上で指定したsub
の値が確認できますね。
これでJWTの発行と検証を行うAuthToken
クラスが作成できました。
コミットする
ここまでの変更をコミットしときましょう。
root $ cd api
api $ git add -A && git commit -m "add_auth_token.rb" && cd ..
root $
まとめ
今回はJWTを扱うAuthToken
クラスを作成しました。
今後は、このクラスを使ってJWTの発行と検証を行います。
- JWTの基本設定は「initializers」の
user_auth.rb - 発行と検証は「services」の
auth_token.rb
と覚えておきましょう。
次回は?
アプリの認証装置である、Authenticator(オーセンティケーター)
モジュールを作成します。
このモジュールは、投げられたトークンを検証し、正しければユーザーを返し、不正なトークンは401unauthorized(アンオーソライズド)エラーを返します。
さてさて、次回をお楽しみに。
修正情報
-
2021年10月05日
目次「JWTを発行する」の
aud
の値をlocalhost:8080から http://localhost:3000 へ変更しました。