• トップ
  • ブログ一覧
  • APIの認証について学んで実装してみた(by rails API)
  • APIの認証について学んで実装してみた(by rails API)

    たけちゃん(エンジニア)たけちゃん(エンジニア)
    2023.11.30

    IT技術

    きっかけ

    業務でrails APIの環境に出会い、railsの勉強がてら個人で同じ構成で何か作ってみようとした時に、APIでの認証へ具体的な理解が浅いなと思ったので、調べてみることにしました。
    この記事では、APIでの認証の基本的な仕組みを理解した上で簡単な実装ができるまでを目指します。

    認証とリソースアクセスのフロー

    さっそくですが、APIサービスでの認証と認証後のリソースへのアクセスフロー(一例)を図にしてみました。



    1. メールアドレスとパスワードを送信する
    2. サーバ側でユーザーの存在を検証する
    3. 検証に問題なければ、クライアントにトークンを返す
    4. クライアントはサーバから送られてきたトークンをリクエストヘッダーに含めてリクエストを送る
    5. サーバはトークンをデコード・検証し、ユーザーの確認が取れた場合、リソースへのアクセスを行う
    6. リクエストに対するレスポンスを返却する

    (RESTfulを意識した)APIはステートレスであることが望ましく、各通信が独立している必要があります。つまり、サーバ側はそのリクエストが誰のものか特定する情報(ここではトークン)を保持しません。

    比較としてセッション方式のフローを図にしてみました。



    この方式だとサーバはセッションという形でユーザーの情報を保持していますが、上のトークン方式の場合、サーバーはトークンを払い出すだけでトークン自体の管理はしていません。

    サーバ側で状態を持たずに認証を可能にする仕組みがトークンベースの認証になります。

    認証処理の鍵は「トークン」

    ここまでで、トークンを使ったAPIの認証フローはイメージできたと思います。

    ここからは、トークン自体に焦点を当てていきます。

    JWT

    APIの認証で一般的に使われるトークンには規格が存在します。それがRFC7519で定義されている「JWT」です。

    JWTは「JSON Web Token」の略で文字通りJSON形式で表現されたトークンです。JWTは大きく3つの構造から成り立ちます。

    • ヘッダー
    • ペイロード
    • 署名

    1つずつ見ていきましょう。

    ヘッダー

    トークンの処理方法に関するメタデータを含みます。具体的にはトークンタイプや署名生成に使われるアルゴリズムを指定します。

    1{
    2  "alg": "HS256",
    3  "typ": "JWT"
    4}

    ペイロード

    トークンの本体で、ユーザー識別情報、有効期限など、様々なクレームを含みます。
    ここでのクレームとは1つの「キー:値」1セットのイメージで大丈夫です。
    JSONのキーが「クレーム名」、値が「クレーム値」という表現がされます。

    ペイロード内のクレームには3種類あります。

    Registered Claim Name

    RFC7519で予約されているクレーム名です。
    IANAの”JSON Web Token Claims”で定義されているクレーム名の内、特に限定して指定されています。

    具体的には以下になります。

    • iss(発行者)
    • sub(件名)
    • aud(オーディエンス)
    • exp(有効期限)
    • nbf(有効になる日時)
    • iat(発行日時)
    • jti(JWTを一意に特定する値)

    Public Claim Name

    IANAの”JSON Web Token Claims”で定義されているクレーム名。
    JWT使用者間で共有される情報(=外部サービスでも使われ得る情報)が該当します。

    Private Claim Name

    特定のアプリケーション間でのみ意味を持ち、一般に公開しないものが該当し、一般的にはこれを利用します。

    ペイロードの具体例は以下になります。

    1{
    2  "jti": "92f46647-90a2-4174-bca9-27d7f69a8fb7",
    3  "exp": 1485320878,
    4  "user_id": 123,
    5  "email": "test@example.com
    6}

    署名

    署名は

    • 誰がそのJWTを発行したか
    • ヘッダーとペイロードは改ざんされていないか

    を保証するために付け加えられます。

    署名生成の流れは以下のイメージです。

    1. ヘッダー・ペイロードをBase64 URLエンコード
    2. それぞれを「.」で連結
    3. 連結文字列をヘッダー「alg」の方式でハッシュ化
    4. ハッシュ化した文字列をサーバの秘密鍵で署名
    5. 署名した値をBase64 URLでエンコード

    JWTの生成とセキュリティ面の考慮

    ここまで見てきたJWTの3つの構造はエンコードされると「.」で繋いだ文字列になります。

    1eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDAwNTQzOTB9.uIALzjUXmOdsFy0vMetZivYoWq5haiufCKeeNQYzFWw

    .区切りで「ヘッダー」「ペイロード」「署名」の構造となっており、この文字列をデコードするとJSON形式になります。

    ここでのエンコードとは、URLにくっつけても大丈夫な形式Base64 URLというフォーマットで行われますが、これは暗号化しているわけでなく、単に形式を変えているだけであり、セキュリティ面を考慮するならHTTPSで通信する必要があります。
    つまり、署名を利用すると改ざんの検知が可能にはなりますが、JWT自体は丸見えのデータです。

    トークン失効戦略

    ここまで、トークン認証のフロートJWTの中身について見てきました。
    冒頭で、サーバはトークンを生成するが、それ自体を管理しないと述べました。ということは、有効期限切れ以外でトークンを無効化させる方法は自前で用意する必要があります。

    そのための方法は複数あり、今回は3つ取り上げます。

    ブラックリスト方式

    トークンに付与される一意のID「jti」を保存するテーブルを用意し、無効化したいトークンのjtiをテーブルに格納していきます。

    このテーブルに保存されているjtiに紐づくトークンは利用できないものとして扱います。

    ホワイトリスト方式

    ブラックリスト方式とは逆に、許可したいトークンのjtiを保存し、このテーブルに紐づくトークンのみ利用可能とします。

    JTI一致方式

    ユーザーテーブルのカラムにjti列を生成し、トークン生成時に現在設定されているjtiの値をJWTに含めます。
    ログアウト時に、ユーザーテーブルのjtiカラムの値を更新することで、既存トークンが送られてきた際に、一致しないため無効と判断できます。
    次回トークンを生成するときは、更新後のjtiの値をトークンに含め、以降ログアウト→ログインで繰り返します。

    ※この記事ではJTI一致方式で実装していきます。

    実装

    ここからは実際にruby on railsでAPIの認証を実装していきます。
    ※前提としてrailsのAPIモードでプロジェクトを作成しているものとします。

    gemのインストール

    Gemfileに以下を記入し、bundle installを実行します。

    1gem 'jwt'

    UserモデルとPostモデルの作成

    各マイグレーションファイルとモデルを作成し、rails db:migrateを実行します。

    • User
      • パスワード暗号化のために、モデルにhas_secure_passwordを書きます。
      • パスワードカラムはpassword_digestという名前にします。
    1## Userモデル
    2class User < ApplicationRecord
    3  has_secure_password
    4  has_many :posts
    5end
    6
    7## usersテーブルマイグレーションファイル
    8class CreateUsers < ActiveRecord::Migration[7.1]
    9  def change
    10    create_table :users do |t|
    11      t.string :name
    12      t.string :email
    13      t.string :password_digest
    14      t.string :jti
    15      t.timestamps
    16    end
    17  end
    18end
    • Post
    1###  Postモデル
    2class Post < ApplicationRecord
    3  belongs_to :user
    4end
    5
    6### postsテーブルのマイグレーションファイル
    7class CreatePosts < ActiveRecord::Migration[7.1]
    8  def change
    9    create_table :posts do |t|
    10      t.references :user, foreign_key: true
    11      t.string :title
    12      t.timestamps
    13    end
    14  end
    15end

    ついでにseederも作成し、rails db:seedを実行してダミーデータを作成します。

    1# usersテーブルにデータを追加
    2user = User.create!(
    3  user_name: 'admin_user',
    4  email: 'test@example.com',
    5  password: 'password'
    6)
    7
    8# postsテーブルにデータを追加
    93.times { |n| Post.create(user_id: 1, title: "テストタイトル#{n + 1}") } if user.user_id == 1

    ログイン処理

    ルーティングとコントローラでログイン処理(トークン生成)を実装します。

    1### config/routes.rb
    2Rails.application.routes.draw do
    3  post "/login", to: 'sessions#create'
    4end
    5
    6### app/controllers/sessions_controller.rb
    7class SessionsController < ApplicationController
    8  def create
    9    user = User.find_by(email: params['email'])
    10
    11    if user && user&.authenticate(params[:password])
    12      jti = user.jti
    13      exp = (Time.now + 1.hour).to_i
    14      payload = {jti: jti, user_id: user.id}
    15      token = JWT.encode(payload, Rails.application.credentials[:secret_key_base])
    16    end
    17    render json: {token: token}
    18  end
    19end

    この部分で行っていることは以下です。

    1. メールアドレス、パスワードの検証(ユーザー情報取得)
    2. 1が成功の場合ペイロード生成
      1. jti
      2. exp
      3. user_id
    3. gem 'jwt'を使ってJWTトークンを生成&Base64 URLエンコード
    4. トークンをレスポンスに含めて返す

      ここまでできたら、http://localhost:3000/loginへリクエストを飛ばしてみます。

      1curl -X POST http://localhost:3000/login \
      2     -H "Content-Type: application/json" \
      3     -d '{"email": "test@example.com", "password": "password"}'

      以下のようなレスポンスが返ってきていたら成功です。

      1{
      2    "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI3MmEwMjgzNi01Mjk1LTQ4ZWUtYjBYTQ2NDUiLCJleHAiOjE3MDA1MDU0MzcsInVzZXJfaWQiOjF9.bmLssfADwPtbWgzCqTi2p4QaXY"
      3}

      トークン検証とリソースアクセス

      生成したトークンを使って今度はリソースへのアクセスを行います。

      app/controllers/application_controller.rb を修正し、各コントローラ処理の前にトークン検証処理をはさみます。(veryfiy_tokenの処理)

      1class ApplicationController < ActionController::API
      2  before_action :verify_token
      3
      4  private
      5
      6  def verify_token
      7    token = request.headers['Authorization'].split(" ")[1]
      8
      9    unless token.present?
      10      render json: { error: 'Authorization header is missing' }, status: :unauthorized
      11      return
      12    end
      13    begin
      14      # トークンをデコード
      15      decoded = HashWithIndifferentAccess.new (
      16        JWT.decode(token, Rails.application.credentials[:secret_key_base])[0]
      17      )
      18
      19      # expが切れているかチェック
      20      if decoded[:exp].nil? || decoded[:exp] < Time.now.to_i
      21        render json: { error: 'Token has expired' }, status: :unauthorized
      22        return
      23      end
      24      # userが存在するかチェック
      25      @current_user = User.find_by(id: decoded[:user_id])
      26      unless @current_user
      27        render json: { error: 'Invalid token' }, status: :unauthorized
      28        return
      29      end
      30      # jtiが有効かチェック
      31      unless decoded[:jti] == @current_user.jti
      32        render json: { error: 'Invalid token' }, status: :unauthorized
      33        return
      34      end
      35    rescue ActiveRecord::RecordNotFound, JWT::DecodeError => e
      36      render json: { error: e.message }, status: :unauthorized
      37    end
      38  end
      39
      40  def current_user
      41    @current_user
      42  end
      43
      44end

      ここで地味に罠なのが、request.headers['Authorization']の値はBearer XXXXXXXXの形式になっていることです。"Bearer"が邪魔なので、半角スペースを区切りに分離し、トークン文字列のみ抽出しています。(しばらくこれに気づかずにエラーと格闘していました。)

      次に、posts_controller.rbを作成します。

      1class PostsController < ApplicationController
      2  def index
      3    posts = @current_user.posts
      4
      5    render json: posts.as_json(only: [:id, :title])
      6  end

      このコントローラーはApplicationControllerを継承しているので、上のトークン検証処理通過して実行されます。

      また、このままだとログイン処理時にトークン検証が強制されてしまうのでスキップする処理を追加します。

      1class SessionsController < ApplicationController
      2  ## これを追加
      3  skip_before_action :verify_token, only: [:create]

      :createアクションのみ、トークン検証処理をスキップする記述です。

      ここで、上で生成されたトークンをAuthorization BearerTokenに指定して/postsにリクエストをしてみます。

      1curl -X GET http://localhost:3000/posts \
      2     -H "Authorization: Bearer トークン文字列"

      このようなレスポンスが戻ってきたら成功です。

      1[
      2    {
      3        "id": 1,
      4        "title": "post1"
      5    },
      6    {
      7        "id": 2,
      8        "title": "post2"
      9    },
      10    {
      11        "id": 3,
      12        "title": "post3"
      13    }
      14]

      ログアウト処理

      最後にログアウト処理を実装します。
      app/controllers/sessions_controller.rbを開き、以下を追加します。

      1  def delete
      2    @current_user.update(jti: SecureRandom.uuid)
      3    render json: {message: 'logout successfully.'}
      4  end

      これは、ログアウト処理時に現在ログインしているユーザーのjtiカラムの値を更新しています。
      そうすることで、前回のjtiをペイロードに持つトークンが送られてきた場合、verify_tokenメソッドで弾くことができます。

      /logoutへリクエストを送り、その後前回のトークンで/postsにリクエストを送ってみるとエラーが返ってくると思います。

      おわりに

      jwt使ったトークン認証について実際に実装してみることで流れのイメージが鮮明になりました。
      ここからリフレッシュトークンの実装やサードパーティの認証機能の組み込みなど展開していくのも面白そうだなと思ったので、引き続き調べてみようと思います。

      ライトコードでは、エンジニアを積極採用中!

      ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

      採用情報へ

      たけちゃん(エンジニア)

      たけちゃん(エンジニア)

      おすすめ記事

      エンジニア大募集中!

      ライトコードでは、エンジニアを積極採用中です。

      特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

      また、フリーランスエンジニア様も大募集中です。

      background