mybook

認証とアクセス制御 — APIキーからOAuth2まで

セキュリティインシデントの予感

「ナツミさん、ちょっといいですか」

セキュリティ担当の吉田さんが神妙な顔でやってきた。

「今の実装、認証が before_action :authenticate_user! だけですよね。JWTトークンの検証ロジック、どこにあります?」

ナツミは答えられなかった。急いで実装した認証は、実はトークンの有効期限すら検証していなかった。

「本番前に直しましょう。認証の設計は一番重要ですから」


認証の種類と選択基準

APIの認証には複数のアプローチがある。

Loading diagram...

Livlyの要件は:

  1. モバイルアプリ(iOS/Android)からのアクセス → JWT
  2. 管理ツールからのサーバー間通信 → APIキー
  3. 将来の外部連携(不動産ポータルへの物件連携)→ OAuth2

JWT認証の実装(devise_token_auth)

インストール

# Gemfile
gem 'devise'
gem 'devise_token_auth'
rails generate devise:install
rails generate devise_token_auth:install User auth
rails db:migrate

ルーティング

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        sessions: 'api/v1/auth/sessions',
        registrations: 'api/v1/auth/registrations',
        passwords: 'api/v1/auth/passwords'
      }
 
      resources :properties
    end
  end
end

カスタムコントローラ

# app/controllers/api/v1/auth/sessions_controller.rb
module Api
  module V1
    module Auth
      class SessionsController < DeviseTokenAuth::SessionsController
        private
 
        def render_create_success
          render json: {
            data: {
              id: @resource.id,
              email: @resource.email,
              name: @resource.name,
              avatar_url: @resource.avatar_url
            }
          }
        end
 
        def render_create_error_bad_credentials
          render json: {
            error: 'メールアドレスまたはパスワードが正しくありません'
          }, status: :unauthorized
        end
      end
    end
  end
end

認証フロー

Loading diagram...

クライアント(iOS)でのリクエスト

// iOSアプリ側のリクエスト例
var request = URLRequest(url: URL(string: "https://api.livly.jp/api/v1/properties")!)
request.setValue(accessToken, forHTTPHeaderField: "access-token")
request.setValue(client, forHTTPHeaderField: "client")
request.setValue(uid, forHTTPHeaderField: "uid")

カスタムJWT実装(よりシンプルなアプローチ)

devise_token_auth が重すぎる場合は、JWTを直接実装する。

# Gemfile
gem 'jwt'
# app/services/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base
  EXPIRATION = 24.hours
 
  def self.encode(payload)
    payload[:exp] = EXPIRATION.from_now.to_i
    JWT.encode(payload, SECRET_KEY, 'HS256')
  end
 
  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })
    HashWithIndifferentAccess.new(decoded[0])
  rescue JWT::ExpiredSignature
    raise AuthenticationError, 'トークンの有効期限が切れています'
  rescue JWT::DecodeError
    raise AuthenticationError, '無効なトークンです'
  end
end
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      before_action :authenticate_user!
 
      private
 
      def current_user
        @current_user ||= begin
          token = request.headers['Authorization']&.split(' ')&.last
          raise AuthenticationError, 'トークンがありません' unless token
 
          payload = JsonWebToken.decode(token)
          User.find(payload[:user_id])
        rescue ActiveRecord::RecordNotFound
          raise AuthenticationError, 'ユーザーが見つかりません'
        end
      end
 
      def authenticate_user!
        current_user
      rescue AuthenticationError => e
        render json: { error: e.message }, status: :unauthorized
      end
    end
  end
end
# app/controllers/api/v1/auth/sessions_controller.rb
module Api
  module V1
    module Auth
      class SessionsController < ActionController::API
        def create
          user = User.find_by(email: session_params[:email])
 
          if user&.authenticate(session_params[:password])
            token = JsonWebToken.encode(user_id: user.id)
            render json: {
              token: token,
              expires_at: 24.hours.from_now.iso8601,
              user: { id: user.id, email: user.email, name: user.name }
            }
          else
            render json: { error: 'メールアドレスまたはパスワードが正しくありません' },
                   status: :unauthorized
          end
        end
 
        private
 
        def session_params
          params.require(:session).permit(:email, :password)
        end
      end
    end
  end
end

アクセス制御(認可)

認証(Authentication)と認可(Authorization)は別物。

  • 認証:「あなたは誰ですか?」
  • 認可:「あなたはこれをして良いですか?」
# Punditを使った認可
gem 'pundit'
# app/policies/property_policy.rb
class PropertyPolicy < ApplicationPolicy
  def show?
    # 公開物件は誰でも、非公開は所有者のみ
    record.published? || record.user == user
  end
 
  def update?
    record.user == user
  end
 
  def destroy?
    record.user == user || user.admin?
  end
 
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true).or(scope.where(user: user))
      end
    end
  end
end
# app/controllers/api/v1/properties_controller.rb
module Api
  module V1
    class PropertiesController < Api::V1::BaseController
      include Pundit::Authorization
 
      def show
        @property = Property.find(params[:id])
        authorize @property  # PolicyのshowをチェックまするかPundit例外
 
        render json: PropertySerializer.new(@property).serializable_hash
      end
 
      def update
        @property = Property.find(params[:id])
        authorize @property  # updateをチェック
 
        if @property.update(property_params)
          render json: PropertySerializer.new(@property).serializable_hash
        else
          render json: { errors: @property.errors.full_messages },
                 status: :unprocessable_entity
        end
      end
 
      rescue_from Pundit::NotAuthorizedError do
        render json: { error: 'この操作を行う権限がありません' }, status: :forbidden
      end
    end
  end
end

OAuth2 の実装(Doorkeeper)

外部サービスからのAPI連携にはOAuth2が標準。

# Gemfile
gem 'doorkeeper'
rails generate doorkeeper:install
rails generate doorkeeper:migration
rails db:migrate
# config/initializers/doorkeeper.rb
Doorkeeper.configure do
  orm :active_record
  resource_owner_authenticator do
    current_user || warden.authenticate!(scope: :user)
  end
 
  # 許可するグラントタイプ
  grant_flows %w[authorization_code client_credentials refresh_token]
 
  # アクセストークンの有効期限
  access_token_expires_in 2.hours
 
  # リフレッシュトークンの有効期限
  refresh_token_expires_in 30.days
 
  # スコープ(権限の粒度)
  default_scopes :read
  optional_scopes :write, :admin
 
  # クライアント側でのシークレット認証を強制
  force_ssl_in_redirect_uri Rails.env.production?
end
# config/routes.rb
use_doorkeeper do
  # デフォルトのビューは必要に応じてカスタマイズ
end
# APIコントローラでDoorkeeperを使った認証
module Api
  module V1
    class BaseController < ActionController::API
      before_action :doorkeeper_authorize!
 
      private
 
      def current_user
        @current_user ||= User.find(doorkeeper_token.resource_owner_id)
      end
 
      # スコープ制限
      def require_write_scope!
        unless doorkeeper_token.scopes.include?('write')
          render json: { error: 'writeスコープが必要です' }, status: :forbidden
        end
      end
    end
  end
end

OAuth2フロー

Loading diagram...

リフレッシュトークンの実装

アクセストークンは短命(1〜2時間)、リフレッシュトークンで再取得する。

# カスタムJWT実装でのリフレッシュトークン
class TokenService
  def self.issue(user)
    access_token = JsonWebToken.encode(
      { user_id: user.id, type: 'access' },
      expires_in: 1.hour
    )
 
    refresh_token = SecureRandom.hex(32)
    user.update!(
      refresh_token: refresh_token,
      refresh_token_expires_at: 30.days.from_now
    )
 
    { access_token: access_token, refresh_token: refresh_token }
  end
 
  def self.refresh(refresh_token_value)
    user = User.find_by(refresh_token: refresh_token_value)
 
    raise AuthenticationError, '無効なリフレッシュトークン' unless user
    raise AuthenticationError, 'リフレッシュトークンが期限切れ' if user.refresh_token_expires_at < Time.current
 
    issue(user)
  end
end

WARNING

リフレッシュトークンはDBに保存する。JWTはステートレスで失効できないため、リフレッシュトークンはデータベースで管理し、ログアウト時や不正使用時に削除できるようにする。


AWS API Gatewayでの認証

本番環境ではAWS API Gatewayで認証の一部を担わせることができる。

# API Gatewayのリソースポリシー例(Terraformイメージ)
resource "aws_api_gateway_authorizer" "jwt_authorizer" {
  name                   = "JWTAuthorizer"
  rest_api_id            = aws_api_gateway_rest_api.livly.id
  authorizer_uri         = aws_lambda_function.jwt_validator.invoke_arn
  authorizer_credentials = aws_iam_role.api_gateway_auth.arn
  type                   = "TOKEN"
  identity_source        = "method.request.header.Authorization"
}
Loading diagram...

まとめ

  • モバイルアプリ → JWT(devise_token_auth または カスタム実装)
  • サーバー間通信 → APIキー(シンプルで管理しやすい)
  • 外部連携 → OAuth2(Doorkeeper gem)
  • 認可はPunditでポリシークラスに集約する
  • リフレッシュトークンはDBで管理し、失効を可能にする
  • AWS API GatewayでJWT検証をアプリの手前で行うことで負荷を軽減できる

次章では、エラーが起きたときに開発者に優しいエラーレスポンスを返す設計を学ぶ。