0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

第28章|今さら学ぶ「API認証とレスポンス設計」

0
Last updated at Posted at 2026-03-02

第28章|今さら学ぶ「API認証とレスポンス設計」

📚 シリーズ目次はこちら → 「今さら学ぶ」シリーズ — はじめに
🗺️ KnowledgeNoteの設計を確認 → 設計マップ

この章でわかること

  • jbuilder — JSONの見た目を整えるテンプレート職人
  • トークン認証の基本 — 「入場チケット」で本人確認する
  • JWT認証 — 「使い捨てパスポート」で身分を証明する
  • OAuth — 「Googleに本人確認を代行してもらう」仕組み
  • エラーレスポンスの統一設計

🏠 たとえ話で掴む「API認証」

Webページのログインは「ホテルのチェックイン」でした(→ 第21章)。API認証は少し違います。

APIはブラウザのCookieが使えない場面が多いため、 入場チケット(トークン) 方式になります。

認証方式 たとえ 特徴
セッション(Cookie) ホテルのルームキー ブラウザ向き。サーバがセッションを管理
トークン認証 入場チケット API向き。リクエストごとにチケットを提示
JWT 使い捨てパスポート 自己完結型。サーバに問い合わせ不要
OAuth 「Googleに聞いてくれ」 他サービスに認証を委託

API認証とは何か — 技術的な定義

第27章で学んだAPIは、URLとHTTPメソッドでデータを操作する仕組みでした。しかし「誰でも DELETE /api/v1/articles/1 できてしまう」状態では困ります。 API認証 は「このリクエストを送っているのは誰か」を確認する仕組みです。

通常のWebアプリでは Cookie + セッション で認証します(→ 第6章、第21章)。ブラウザが自動的にCookieを付けてくれるので、ユーザーは意識せずに認証済みの状態を維持できます。

しかしAPIのクライアントはブラウザとは限りません。スマホアプリ、他社のサーバ、CLIツールなど、Cookieの仕組みが使えない環境も多いです。そこで トークン認証 が使われます。クライアントは毎回のリクエストに トークン(認証情報を表す文字列) を付けて、自分が誰かを証明します。

【セッション認証(ブラウザ向き)】
ブラウザ → Cookie: session_id=abc123 → サーバがセッションを検索 → ユーザー特定

【トークン認証(API向き)】
クライアント → Authorization: Bearer xyz789 → サーバがトークンを検証 → ユーザー特定

トークン認証にもいくつかの方式がありますが、この章では シンプルなAPIトークン、 JWT、 OAuth の3つを扱います。

もう1つ重要なのが レスポンス設計 です。HTMLを返す通常のアプリと違い、APIはJSONで情報を返します。どのフィールドを含めるか、エラー時はどんな形式で返すかといった「JSONの設計」も、APIの使いやすさを左右します。


🎨 jbuilder — JSONの見た目を整える

第27章では serialize_article メソッドでJSONを手動で組み立てました。これでも動きますが、レスポンスが複雑になると管理しづらくなります。

jbuilder は、APIのJSONレスポンスをERBのように テンプレート で組み立てるGemです。Railsチームが管理しているgemで、Gemfileに追加して使います。

# Gemfile
gem "jbuilder"
# app/views/api/v1/articles/show.json.jbuilder
json.id @article.id
json.title @article.title
json.body @article.body
json.status @article.status
json.created_at @article.created_at.iso8601

json.author do
  json.id @article.user.id
  json.name @article.user.name
end

json.tags @article.tags do |tag|
  json.id tag.id
  json.name tag.name
end

json.likes_count @article.likes.count
json.comments_count @article.comments.count
// 出力されるJSON
{
  "id": 1,
  "title": "Railsのルーティングを完全理解する",
  "body": "ルーティングとは...",
  "status": "published",
  "created_at": "2025-02-15T10:30:00Z",
  "author": { "id": 3, "name": "田中太郎" },
  "tags": [
    { "id": 1, "name": "Ruby" },
    { "id": 3, "name": "Rails" }
  ],
  "likes_count": 5,
  "comments_count": 2
}

コントローラ側は render json: ではなく、通常の render でjbuilderテンプレートを呼び出します。

# app/controllers/api/v1/articles_controller.rb
def show
  @article = Article.find(params[:id])
  # → 自動的に app/views/api/v1/articles/show.json.jbuilder が使われる
end

jbuilder の Partial

ビューのPartial(→ 第13章)と同じように、jbuilderでも共通のJSONテンプレートを切り出せます。

# app/views/api/v1/articles/_article.json.jbuilder
json.extract! article, :id, :title, :status, :created_at
json.author do
  json.extract! article.user, :id, :name
end

# app/views/api/v1/articles/index.json.jbuilder
json.articles @articles, partial: "api/v1/articles/article", as: :article
json.total_count @articles.total_count
json.current_page @articles.current_page

json.extract! は指定したカラムだけを抜き出す便利メソッドです。password_digest などの機密情報を含めてしまうリスクを減らせます。


🎫 トークン認証 — 入場チケット方式

最もシンプルなAPI認証は、 APIトークン をHTTPヘッダーに付けてリクエストする方式です。

# リクエスト(クライアント側)
GET /api/v1/articles HTTP/1.1
Authorization: Bearer abc123xyz789
#                     ↑ トークン(入場チケット)
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      before_action :authenticate_api_user!

      private

      def authenticate_api_user!
        token = request.headers["Authorization"]&.split(" ")&.last
        @current_api_user = User.find_by(api_token: token)

        unless @current_api_user
          render json: { error: "認証に失敗しました" }, status: :unauthorized
        end
      end

      def current_api_user
        @current_api_user
      end
    end
  end
end

この方式はシンプルですが、トークンが漏洩したら悪用されるリスクがあります。トークンの 有効期限がない のも弱点です。これを改善したのがJWTです。

💡 ActionController::API は ApplicationController(ActionController::Base)の軽量版です。APIに不要なCSRF対策やCookie処理、ビューのレンダリング機能が省かれているため、APIコントローラの基底クラスに適しています。


🛂 JWT(JSON Web Token)— 使い捨てパスポート

JWT(ジョット) は、ユーザー情報を暗号化して トークン自体に埋め込む 方式です。サーバ側でセッションを保持しなくて済むため、 ステートレス な認証が実現できます。

JWTの構造(3つの部分をドットで繋いだ文字列)

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MDgwODAwMDB9.xxx
├── Header ────────┤├── Payload ──────────────────────────┤├── Signature ┤
    (暗号方式)         (ユーザーID, 有効期限 等)             (署名)

Header — 暗号化アルゴリズム(HS256等)を指定します。

Payload — ユーザーIDや有効期限(exp)などの情報を含みます。Base64でエンコードされているだけなので、 暗号化はされていません。誰でも中身を読めます。パスワードのような機密情報は絶対に入れてはいけません。

Signature — HeaderとPayloadを秘密鍵で署名したもの。この署名のおかげで「トークンが改ざんされていないか」を検証できます。

# Gemfile
gem "jwt"
# app/services/jwt_service.rb
class JwtService
  # JWT署名用の専用秘密鍵(credentials に jwt_secret_key を追加して管理する)
  # 例: EDITOR=vi rails credentials:edit で以下を追記
  #   jwt_secret_key: "ランダムな長い文字列(bin/rails secret で生成)"
  #
  # ⚠️ secret_key_base(Railsセッション署名用の鍵)を流用しない。
  #   JWT専用の鍵を別途用意するのがベストプラクティス。
  SECRET_KEY = Rails.application.credentials.jwt_secret_key

  # トークンを発行する
  def self.encode(user_id, exp: 24.hours.from_now)
    payload = { user_id: user_id, exp: exp.to_i }
    JWT.encode(payload, SECRET_KEY)
  end

  # トークンを検証してユーザーIDを取り出す
  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY).first
    decoded["user_id"]
  rescue JWT::DecodeError, JWT::ExpiredSignature
    nil
  end
end

ログインAPI(トークン発行)

# app/controllers/api/v1/sessions_controller.rb
module Api
  module V1
    class SessionsController < BaseController
      skip_before_action :authenticate_api_user!, only: [:create]

      # POST /api/v1/login
      def create
        user = User.authenticate_by(
          email_address: params[:email_address],
          password: params[:password]
        )

        if user
          token = JwtService.encode(user.id)
          render json: { token: token, user: { id: user.id, name: user.name } }
        else
          render json: { error: "メールアドレスまたはパスワードが正しくありません" },
                 status: :unauthorized
        end
      end
    end
  end
end

BaseControllerでJWT認証に切り替える

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      before_action :authenticate_api_user!

      private

      def authenticate_api_user!
        token = request.headers["Authorization"]&.split(" ")&.last
        user_id = JwtService.decode(token)
        @current_api_user = User.find_by(id: user_id)

        unless @current_api_user
          render json: { error: "認証に失敗しました" }, status: :unauthorized
        end
      end

      def current_api_user
        @current_api_user
      end
    end
  end
end

クライアント側の使い方は以下の流れです。

① POST /api/v1/login (メールアドレス+パスワードを送信)
   → レスポンス: { "token": "eyJhbG..." }

② GET /api/v1/articles (Authorization ヘッダーにトークンを付ける)
   → Authorization: Bearer eyJhbG...
   → レスポンス: 記事一覧のJSON

🤝 OAuth — 他サービスに認証を委託する

OAuth は「Googleに本人確認を代行してもらう」仕組みです。「Googleでログイン」「GitHubでログイン」はこの仕組みです。

① ユーザーが「Googleでログイン」ボタンを押す
    ↓
② KnowledgeNote が Google に「この人を認証してください」とリダイレクト
    ↓
③ ユーザーが Google のログイン画面でログインする
    ↓
④ Google が KnowledgeNote に「認証OK、この人のメールはxxx@gmail.comです」と返す
    (コールバックURLにリダイレクト + 認可コードを付与)
    ↓
⑤ KnowledgeNote が認可コードをアクセストークンに交換し、
    メールアドレスでユーザーを検索 or 作成してログイン

ユーザーのパスワードを KnowledgeNote が受け取る必要がないため、セキュリティ面で優れています。

RailsでのOAuth実装(OmniAuth)

# Gemfile
gem "omniauth"
gem "omniauth-google-oauth2"
gem "omniauth-rails_csrf_protection"  # CSRF対策
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :google_oauth2,
    Rails.application.credentials.dig(:google, :client_id),
    Rails.application.credentials.dig(:google, :client_secret)
end

OAuthの実装は設定項目が多いため、この章では流れだけを押さえる。実装の詳細は公式ドキュメントが詳しい。


⚠️ エラーレスポンスの統一設計

APIのエラーは 統一したフォーマット で返すのがベストプラクティスです。クライアント側が「どの形式でエラーが返ってくるか」を予測できないと、エラーハンドリングが困難になります。

// 成功時
{
  "id": 1,
  "title": "Ruby入門"
}

// エラー時(バリデーション)
{
  "errors": [
    "タイトルを入力してください",
    "本文を入力してください"
  ]
}

// エラー時(認証)
{
  "error": "認証に失敗しました"
}

// エラー時(権限)
{
  "error": "この操作は許可されていません"
}
# app/controllers/api/v1/base_controller.rb(エラーハンドリング部分)
rescue_from ActiveRecord::RecordNotFound do |e|
  render json: { error: "リソースが見つかりません" }, status: :not_found
end

rescue_from Pundit::NotAuthorizedError do |e|
  render json: { error: "この操作は許可されていません" }, status: :forbidden
end

rescue_from ActionController::ParameterMissing do |e|
  render json: { error: "パラメータが不足しています: #{e.param}" }, status: :bad_request
end

ここで重要なのは、バリデーションエラーは 配列 (errors)、それ以外は 単一メッセージ (error)で返すパターンです。クライアント側は errors キーがあれば複数のエラーをまとめて表示し、error キーなら1つのメッセージを表示する、という実装ができます。


🛠️ KnowledgeNoteでの具体例

KnowledgeNoteでは、HTMLビュー用の通常コントローラとAPI用コントローラの両方を持ちます。認証方式が異なるだけで、ビジネスロジック(Service Object等)は共通で使えます。

# 通常のコントローラ(セッション認証 + HTML)
class ArticlesController < ApplicationController
  def create
    @article = current_user.articles.build(article_params)
    result = ArticlePublishService.new(
      article: @article,
      tag_names: params[:tag_names],
      user: current_user
    ).call

    if result
      redirect_to @article, notice: "記事を投稿しました"
    else
      render :new, status: :unprocessable_entity
    end
  end
end

# APIコントローラ(JWT認証 + JSON)
module Api
  module V1
    class ArticlesController < BaseController
      def create
        @article = current_api_user.articles.build(article_params)
        result = ArticlePublishService.new(
          article: @article,
          tag_names: params[:tag_names],
          user: current_api_user
        ).call

        if result
          render json: serialize_article(@article), status: :created
        else
          render json: { errors: @article.errors.full_messages },
                 status: :unprocessable_entity
        end
      end
    end
  end
end

Service Object(→ 第25章)を使ってビジネスロジックを切り出しておけば、HTMLコントローラとAPIコントローラで 同じロジックを共有 できます。違うのは認証方式とレスポンス形式だけです。


💼 面接で聞かれたら?

Q:JWTとは何ですか?

「JWTはJSON Web Tokenの略で、ユーザー情報と有効期限を暗号化してトークンに埋め込む認証方式です。サーバ側でセッションを保持しないステートレスな仕組みなので、API認証に適しています。トークンはHeader・Payload・Signatureの3部構成で、秘密鍵で署名されているため改ざんを検知できます。有効期限を短く設定し、期限切れトークンは拒否する仕組みにします。」

深掘りされたら:

  • 「OAuthとは?」→ GoogleやGitHubなどの外部サービスに認証を委託する仕組み。アプリがユーザーのパスワードを直接受け取らないためセキュリティが高い。「Googleでログイン」はこの仕組み。
  • 「JWTのPayloadは暗号化されている?」→ Base64エンコードされているだけで、暗号化はされていない。誰でもデコードして中身を読める。パスワードなどの機密情報は絶対に入れてはいけない。署名(Signature)は改ざん検知のためのもので、中身を隠すためのものではない。
  • 「セッション認証とトークン認証の使い分けは?」→ ブラウザ(HTMLアプリ)はCookieベースのセッション認証が自然。スマホアプリやSPA、外部連携などブラウザ以外のクライアントにはトークン認証が適している。

🔗 もっと深く知りたい人へ(1次情報リンク)


まとめ

  • ✅ API認証は「入場チケット」方式。CookieではなくAuthorizationヘッダーにトークンを付ける
  • ✅ jbuilderでJSONレスポンスをテンプレートとして整形。extract! やPartialも使える
  • ✅ JWTはトークン自体にユーザー情報を埋め込む。ステートレスでAPI認証に最適
  • ✅ JWTのPayloadは暗号化されていない。機密情報を入れてはいけない
  • ✅ OAuthは外部サービスに認証を委託。パスワードを預からないので安全
  • ✅ エラーレスポンスは統一フォーマットにする。バリデーションは配列、それ以外は単一メッセージ
  • ✅ Service Objectを使えば、HTMLコントローラとAPIコントローラでロジックを共有できる

📚 シリーズ目次:「今さら学ぶ」シリーズ — はじめに

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?