背景
レコードを更新するために、APIの用意が必要になってきました。APIの認証はセキュリティを考慮して、HMAC認証を採用しました。
環境
- クライアント側
- Google Cloud Run Function
- サーバー側
- AWS ECS: Rails
HMACとは
HMAC (Hash-based Message Authentication Code) は、秘密鍵とハッシュ関数を使用したメッセージ認証の仕組みです。主な利点は:
- 秘密鍵を知らないと正しいHMACを生成できないため安全
- 多くの言語でライブラリが提供されており実装が容易
- サーバー側でユーザー状態を保持する必要がなくスケーラブル
- タイムスタンプと組み合わせることでリプレイアタックを防止可能
実装の重要ポイント
1. 秘密鍵の管理
- 安全な生成: 32バイト(256ビット)以上の長さで生成
- 環境別の管理: 開発・ステージング・本番環境で異なる鍵を使用
- Google Cloud Functions: 環境変数として設定
- AWS ECS: Parameter Storeを使用して安全に保存・取得
2. タイムスタンプと有効期限
- UTC時間を使用: タイムゾーンの差異を防止
-
ISO8601形式: 言語間の互換性確保(例:
2025-05-21T14:30:00Z
) - 有効期限の設定: 5分以内など短時間に制限し、リプレイアタック防止
3. 一貫した署名データ構造
[タイムスタンプ ISO8601形式]
[HTTPメソッド]
[エンドポイントパス]
[リクエストボディJSON]
- クライアントとサーバーで必ず同じ形式・順序で構成
- 改行コード(
\n
)は正確に一致させる
4. HTTPヘッダーの標準化
-
X-HMAC-Signature
: 計算したHMAC署名 -
X-HMAC-Timestamp
: ISO8601形式のタイムスタンプ -
Content-Type: application/json
: JSONデータの場合
フロー
秘密鍵の生成と管理
- 十分な長さと複雑さを持つ鍵を使用
- 環境ごとに異なる鍵を使用(開発・ステージング・本番)
# Rubyでの安全な秘密鍵生成例
require 'securerandom'
# 32バイト(256ビット)のランダム文字列を生成
api_secret_key = SecureRandom.hex(32)
puts api_secret_key # => "59b5..."
クライアント側(Google Cloud Functions)での鍵管理
Google Cloud Functionsでは環境変数を使用して秘密鍵を管理します:
# デプロイ時に環境変数を設定
gcloud functions deploy function-name \
--set-env-vars API_SECRET_KEY=your-secret-key \
--other-parameters
または、Google Cloud Console上で環境変数を設定することもできます。
サーバー側(AWS ECS + Rails)での鍵管理
AWS Parameter Storeを使用して秘密鍵を安全に保存し、アプリケーションから取得します:
# AWS Parameter Storeから秘密鍵を取得する例
require 'aws-sdk-ssm'
ssm_client = Aws::SSM::Client.new(region: 'ap-northeast-1')
parameter = ssm_client.get_parameter({
name: '/myapp/production/api_secret_key',
with_decryption: true
})
ENV['API_SECRET_KEY'] = parameter.parameter.value
ECSタスク定義では、Parameter Store からの値取得を許可するIAMロールをタスクに割り当てることが必要です。
クライアント側の実装 (Cloud Run Function - Python)
import os
import json
import hmac
import hashlib
import requests
import datetime
from urllib.parse import urljoin
def send_api_request(item_id, operation_data):
"""サンプルAPI呼び出し関数"""
# 秘密鍵の取得
hmac_secret = os.getenv('API_SECRET_KEY')
# UTCタイムスタンプの生成
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
# リクエスト情報の準備
method = 'PATCH'
endpoint = f"/api/items/{item_id}"
payload = operation_data
json_payload = json.dumps(payload)
# 署名用データ作成
data_to_sign = f"{timestamp}\n{method}\n{endpoint}\n{json_payload}"
# HMAC-SHA256署名計算
signature = hmac.new(
hmac_secret.encode('utf-8'),
data_to_sign.encode('utf-8'),
hashlib.sha256
).hexdigest()
# APIリクエスト送信
url = urljoin(os.getenv('API_BASE_URL'), endpoint)
headers = {
'Content-Type': 'application/json',
'X-HMAC-Signature': signature,
'X-HMAC-Timestamp': timestamp
}
response = requests.patch(url, data=json_payload, headers=headers)
return response
サーバー側の実装 (Rails on AWS ECS)
# frozen_string_literal: true
class SecuredApiController < ApplicationController
HMAC_MAX_AGE = 300 # 5分間の有効期限
before_action :authenticate_request
private
# HMACによるリクエスト認証
def authenticate_request
return unauthorized('署名がありません') unless hmac_headers_present?
return bad_request('タイムスタンプが不正です') unless valid_timestamp_format?
return unauthorized('リクエスト期限切れ') unless timestamp_fresh?
return unauthorized('署名が無効です') unless valid_signature?
end
# 必要なヘッダーが存在するか
def hmac_headers_present?
request.headers['X-HMAC-Signature'].present? &&
request.headers['X-HMAC-Timestamp'].present?
end
# タイムスタンプの形式が正しいか
def valid_timestamp_format?
@timestamp = Time.iso8601(request.headers['X-HMAC-Timestamp'])
true
rescue ArgumentError
false
end
# タイムスタンプが期限内か
def timestamp_fresh?
(Time.current - @timestamp).abs < HMAC_MAX_AGE
end
# 署名が正しいか
def valid_signature?
data = "#{request.headers['X-HMAC-Timestamp']}\n#{request.method}\n#{request.path}\n#{request.raw_post}"
expected = OpenSSL::HMAC.hexdigest('sha256', ENV['API_SECRET_KEY'], data)
ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-HMAC-Signature'])
end
# 認証エラーレスポンス
def unauthorized(message)
render json: { error: message }, status: :unauthorized
end
# リクエストエラーレスポンス
def bad_request(message)
render json: { error: message }, status: :bad_request
end
end