認証と認可 — 誰が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
endWARNING
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
endOAuth 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を壊さずに進化させる「バージョニング戦略」を学ぶ。