Ruby on Railsの習得のためタスクリスト的なの作ってみる⑤〜認証・トークン発行編〜

ここまでの記事はこちら
[Ruby on Railsの習得のためタスクリスト的なの作ってみる①〜構成、設計編〜](https://qiita.com/pyon_snore/items/6e35f291910fcc541116)
[Ruby on Railsの習得のためタスクリスト的なの作ってみる②〜Rails7.0+React18.0+ MySQL8.0 Docker開発環境構築編〜](https://qiita.com/pyon_snore/items/b8d860e5e3d94a322dfd)
[Ruby on Railsの習得のためタスクリスト的なの作ってみる③〜API開発・呼び出し編〜](https://qiita.com/pyon_snore/items/16b37134751e123ec36b)
[Ruby on Railsの習得のためタスクリスト的なの作ってみる④〜API開発・MySQL接続とモデル実装編〜](https://qiita.com/pyon_snore/items/1ad3d19c08bef12ea5cc)

今回は、認証周りを実装します。

# 構成
ログインAPIでは、アクセストークンとリフレッシュトークンをJWTで発行します。
アクセストークンはレスポンスボディで返却、リフレッシュトークンはCookieに追加します。

トークン更新APIでは、Cookieから取得したリフレッシュトークンで認証し、アクセストークンとリフレッシュトークンを再発行します。

# JWTとは
https://developer.mamezou-tech.com/blogs/2022/12/08/jwt-auth/

こちらがわかりやすいかと思います。

簡単に説明すると、**HTTPヘッダに情報を付加するためのJSONの形式**
であり、一般に
・改竄されにくく、
・改竄を検知できる
ように運用することでログイン情報を保持する用法で使われることの多い形式です。

# モデル準備

リフレッシュトークンの発行をイベントとしてテーブルに保存することで**ユーザーとの紐付けを簡単に**し、かつ、**同一リフレッシュトークンによるトークン再発行を一度限りとする**ことを実現します。

```/db/migrate/******_create_token_issue_events.rb
class CreateTokenIssueEvents < ActiveRecord::Migration[7.0]
  def change
    create_table :token_issue_events do |t|
      t.references :user, foreign_key: true, null: false
      t.text :refresh_token, null: false
      t.datetime :event_datetime, null: false
    end
  end
end
```

ユーザーテーブルに関しては、`password_digest`カラムを追加しておきます。
これにより、ActiveModelの`authenticate`メソッドが使えるようになります。
詳しくは[こちらのドキュメント](https://railsguides.jp/active_model_basics.html#securepassword%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB)に書いてあります。
また、Userモデルクラスに`has_secure_password`メソッドを追加しておく必要があります。
```/db/migrate/******_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :password_digest, null: false
      t.string :name, null: false
      t.string :mail_address, null: false
    end

    add_index :users, [:mail_address], unique: true
  end
end
```

# ログイン認証

`authenticate`メソッドは、一件取得したUserインスタンスに対して呼び出すと、以下のように、パスワードを引数として渡すだけで認証ができてしまいます。

```/app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < ApplicationController
      def login
        mail_address = params[:mail_address]
        password = params[:password]

        user = User.find_by(mail_address:)
        if user.nil?
          # ユーザーが存在しない場合の処理
        end

        authed_user = user.authenticate(password)
        unless authed_user
          # パスワードによる認証に失敗した場合の処理
        end
        # 正常な場合の処理 ここでトークンの発行を行う（次章）
      end
    end
  end
end
```

# トークンの発行

https://github.com/jwt/ruby-jwt

上記リンクのReadmeに従ってgemをインストールすると、`JWT.encode`と`JWT.decode`を主に使ってJWTのエンコード、デコードができるようになります。

**トークンの生成のコード例**は以下のようになります。アクセストークンもリフレッシュトークンも同様に生成できます。
また、この例ではRS256アルゴリズムを使っていますが、他の暗号アルゴリズムを使いたい場合は上記リンクのReadmeを参照して下さい。

```rb
def generate_access_token(subject)
  rsa_private = File.open('file_name_of_rsa', 'r').read
  private_key = OpenSSL::PKey::RSA.new(rsa_private)
  exipire_access = 60.minute # 有効期限

  JWT.encode(
    { iss: config.jwt.issuer,
      sub: subject,
      exp: Time.current.since(exipire_access).to_i },
    private_key,
    'RS256'
  )
end
```

# トークンの認証
**トークン認証のコード例**は以下のようになります。

```rb
def decode_token(token)
  rsa_public = File.open('file_name_of_rsa.pub', 'r').read
  public_key = OpenSSL::PKey::RSA.new(rsa_public)

  begin
    decoded_token = JWT.decode(token, public_key, true, { algorithm: 'RS256' })
  rescue JWT::VerificationError => e
    # 認証エラー時の処理
  rescue JWT::ExpiredSignature => e
    # 有効期限切れ時の処理
  end

  decoded_token
end
```

以上のトークン生成・認証をいい感じにControllerから呼び出すことでログインAPIやトークン更新APIを実装することができます。

例えば、[ログイン認証](#ログイン認証)で認証した後に、以下のように
・リフレッシュトークンをCookieにセット
・トークン更新イベントテーブルにレコード保存
・アクセストークンを返却
するといい感じになります！
```rb
access_token = TokenHandler.generate_access_token(authed_user.id)
refresh_token = TokenHandler.generate_refresh_token(authed_user.id)
cookies[:jwt] = refresh_token

token_issue_event = TokenIssueEvent.new(
  { user_id: authed_user.id, refresh_token:, event_datetime: Time.current }
)

token_issue_event.save

render json: { access_token: }
```

# 次回予告
これで一応バックエンドのAPIが動くようになったので、次回はReactでフロントを作ります。サクッといきたい...！