0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HMACを使ったAPIのセキュア認証の実装

Posted at

背景

レコードを更新するために、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

参照

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?