mybook

認証と認可 — 誰がAPIを使えるか

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

「サクラさん、変なログが出てます」

インフラ担当のケンタが深刻な顔でモニターを指した。ログには見覚えのないIPアドレスからの大量リクエストが流れていた。

「認証なしのAPIに叩かれてる。このままだとデータが全部見られる」

サクラは青ざめた。パブリックAPIのリリース直前、認証設計が最重要課題になった。

認証と認可の違い

まず概念を整理する。

Loading diagram...
概念質問
認証 (Authentication)あなたは誰?ログイン、APIキー確認
認可 (Authorization)何ができる?管理者のみ削除可能

APIキー認証

最もシンプルな認証方式。パートナー企業ごとにAPIキーを発行する。

Loading diagram...

実装

# db/migrate/xxxx_create_api_keys.rb
class CreateApiKeys < ActiveRecord::Migration[7.1]
  def change
    create_table :api_keys do |t|
      t.references :partner, null: false, foreign_key: true
      t.string :key, null: false, index: { unique: true }
      t.string :name
      t.datetime :last_used_at
      t.datetime :expires_at
      t.boolean :active, default: true
 
      t.timestamps
    end
  end
end
 
# app/models/api_key.rb
class ApiKey < ApplicationRecord
  belongs_to :partner
 
  before_create :generate_key
 
  scope :active, -> { where(active: true).where("expires_at IS NULL OR expires_at > ?", Time.current) }
 
  def use!
    update_columns(last_used_at: Time.current)
  end
 
  private
 
  def generate_key
    self.key = "sk_#{SecureRandom.hex(32)}"
  end
end
 
# app/controllers/concerns/api_key_authenticatable.rb
module ApiKeyAuthenticatable
  extend ActiveSupport::Concern
 
  included do
    before_action :authenticate_by_api_key!
  end
 
  private
 
  def authenticate_by_api_key!
    api_key_value = request.headers["X-API-Key"]
 
    unless api_key_value.present?
      return render_error(
        code: "missing_api_key",
        message: "APIキーが必要です",
        status: :unauthorized
      )
    end
 
    @api_key = ApiKey.active.find_by(key: api_key_value)
 
    unless @api_key
      return render_error(
        code: "invalid_api_key",
        message: "無効なAPIキーです",
        status: :unauthorized
      )
    end
 
    @api_key.use!
    @current_partner = @api_key.partner
  end
end

WARNING

APIキーはログに残さないでください。リクエストログにキーが含まれると、ログを見た人全員がアクセスできるようになります。

JWT認証

エンドユーザー向けのAPIには、JWTが適している。

JWTの構造

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.xxxxx
      ↑ Header         ↑ Payload          ↑ Signature
// Header
{ "alg": "HS256", "typ": "JWT" }
 
// Payload
{
  "user_id": 1,
  "exp": 1705312800,  // 有効期限
  "iat": 1705309200   // 発行時刻
}

Rails実装

# Gemfile
gem 'jwt'
 
# app/services/jwt_service.rb
class JwtService
  SECRET = Rails.application.secret_key_base
  ALGORITHM = "HS256"
  EXPIRATION = 24.hours
 
  def self.encode(payload)
    payload[:exp] = EXPIRATION.from_now.to_i
    JWT.encode(payload, SECRET, ALGORITHM)
  end
 
  def self.decode(token)
    decoded = JWT.decode(token, SECRET, true, { algorithm: ALGORITHM })
    HashWithIndifferentAccess.new(decoded[0])
  rescue JWT::ExpiredSignature
    raise AuthenticationError, "トークンの有効期限が切れています"
  rescue JWT::DecodeError
    raise AuthenticationError, "無効なトークンです"
  end
end
 
# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
  extend ActiveSupport::Concern
 
  included do
    before_action :authenticate_user!
  end
 
  private
 
  def authenticate_user!
    token = extract_token_from_header
    raise AuthenticationError, "認証トークンがありません" unless token
 
    payload = JwtService.decode(token)
    @current_user = User.find(payload[:user_id])
  rescue AuthenticationError => e
    render_error(code: "unauthorized", message: e.message, status: :unauthorized)
  rescue ActiveRecord::RecordNotFound
    render_error(code: "unauthorized", message: "ユーザーが見つかりません", status: :unauthorized)
  end
 
  def extract_token_from_header
    auth_header = request.headers["Authorization"]
    auth_header&.split("Bearer ")&.last
  end
end
 
# app/controllers/api/v1/auth_controller.rb
class Api::V1::AuthController < ApplicationController
  skip_before_action :authenticate_user!
 
  def login
    user = User.find_by(email: params[:email])
 
    unless user&.authenticate(params[:password])
      return render_error(
        code: "invalid_credentials",
        message: "メールアドレスまたはパスワードが正しくありません",
        status: :unauthorized
      )
    end
 
    token = JwtService.encode({ user_id: user.id })
    render json: {
      data: {
        token: token,
        expires_at: 24.hours.from_now.iso8601,
        user: UserSerializer.new(user).serializable_hash
      }
    }
  end
end

OAuth 2.0

サードパーティアプリがユーザーの代わりにAPIを呼び出す場合はOAuth 2.0が必要だ。

Loading diagram...

Doorkeeperで実装

# 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 || redirect_to(new_user_session_url)
  end
 
  # スコープ定義
  default_scopes :read
  optional_scopes :write, :admin
 
  # アクセストークンの有効期限
  access_token_expires_in 2.hours
 
  # リフレッシュトークンを有効化
  use_refresh_token
 
  # 許可するグラントタイプ
  grant_flows %w[authorization_code client_credentials]
end
 
# routes.rb
use_doorkeeper

スコープによる認可

# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
  before_action -> { doorkeeper_authorize! :read }, only: [:index, :show]
  before_action -> { doorkeeper_authorize! :write }, only: [:create, :update, :destroy]
 
  def create
    @article = current_resource_owner.articles.build(article_params)
    # ...
  end
 
  private
 
  def current_resource_owner
    User.find(doorkeeper_token.resource_owner_id)
  end
end

スコープ設計

スコープはAPIの権限を細かく制御する。

# 設計例
SCOPES = {
  "read:users"    => "ユーザー情報の読み取り",
  "write:users"   => "ユーザー情報の書き込み",
  "read:articles" => "記事の読み取り",
  "write:articles"=> "記事の書き込み",
  "admin"         => "管理者権限(全操作)"
}

INFO

スコープは「最小権限の原則」で設計します。パートナーが必要な権限だけを要求し、それ以上は付与しない。

AWS API Gatewayの認証設定

# CloudFormation
ApiGatewayAuthorizer:
  Type: AWS::ApiGateway::Authorizer
  Properties:
    RestApiId: !Ref ApiGateway
    Name: JwtAuthorizer
    Type: TOKEN
    AuthorizerUri: !Sub
      - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations
      - LambdaArn: !GetAtt AuthorizerLambda.Arn
    IdentitySource: method.request.header.Authorization
    AuthorizerResultTtlInSeconds: 300
# Lambda Authorizer (Python)
import jwt
import os
 
def lambda_handler(event, context):
    token = event['authorizationToken'].replace('Bearer ', '')
 
    try:
        payload = jwt.decode(token, os.environ['JWT_SECRET'], algorithms=['HS256'])
        return generate_policy(payload['user_id'], 'Allow', event['methodArn'])
    except Exception:
        return generate_policy('user', 'Deny', event['methodArn'])
 
def generate_policy(principal_id, effect, resource):
    return {
        'principalId': principal_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [{
                'Action': 'execute-api:Invoke',
                'Effect': effect,
                'Resource': resource
            }]
        }
    }

セキュリティのベストプラクティス

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # HTTPS強制
  before_action :require_ssl!
 
  # セキュリティヘッダー
  after_action :set_security_headers
 
  private
 
  def require_ssl!
    unless request.ssl? || Rails.env.development?
      render_error(
        code: "ssl_required",
        message: "HTTPSが必要です",
        status: :forbidden
      )
    end
  end
 
  def set_security_headers
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
  end
end

サクラの気づき

「認証と認可、全然違うんですね」

APIキーはシンプルで強力だが、ユーザー委譲はOAuthが必要だ。JWTはステートレスで水平スケールに向いている。

それぞれのユースケースに合った認証方式を選ぶのが、良いAPI設計者の仕事だ。

次章では、APIを壊さずに進化させる「バージョニング戦略」を学ぶ。