mybook

認証の基礎 — 本人確認の仕組み

「認証と認可の違いを説明できるか?」

CTOのアキラがリョウに尋ねた。セキュリティ強化ミーティングの冒頭だった。

「認証(Authentication)は誰であるかを確認すること、認可(Authorization)は何をしてよいかを決めることです」

「正解だ。今のStockFlowのAPIは、どちらも甘い。まず認証から固めよう」

認証方式の比較

APIの認証方式にはいくつかの選択肢がある。

Loading diagram...
方式用途セキュリティ利便性
Basic認証内部API、開発環境低(Base64のみ)
APIキーサーバー間通信
Bearer Tokenユーザー認証高(有効期限付き)
mTLS高セキュリティ環境最高

Basic認証の問題点と対処

Basic認証はユーザー名とパスワードをBase64エンコードして送信するだけだ。

# Basic認証のデコード例(攻撃者も同じことができる)
require 'base64'
encoded = "dXNlcjpwYXNzd29yZA=="
Base64.decode64(encoded)  # => "user:password"

WARNING

Base64はエンコードであり、暗号化ではありません。TLSなしでBasic認証を使うと、パスワードが平文で流れているのと同じです。Basic認証は内部システムの管理API以外では使用しないことを推奨します。

内部管理APIでBasic認証を使う場合でも、適切な強化が必要だ。

# app/controllers/admin/base_controller.rb
module Admin
  class BaseController < ApplicationController
    before_action :authenticate_admin!
 
    private
 
    def authenticate_admin!
      authenticate_or_request_with_http_basic("Admin Area") do |username, password|
        # 固定文字列比較ではなく、タイミング攻撃を防ぐsecure_compare
        admin = Admin.find_by(username: username)
        return false unless admin
 
        ActiveSupport::SecurityUtils.secure_compare(
          admin.password_digest,
          BCrypt::Password.create(password)
        )
      end
    end
  end
end

APIキー認証

サービス間通信(外部向けAPI、Webhookなど)にはAPIキーが適している。

# db/migrate/20240115000001_create_api_keys.rb
class CreateApiKeys < ActiveRecord::Migration[7.1]
  def change
    create_table :api_keys do |t|
      t.references :user, null: false, foreign_key: true
      t.string :key_digest, null: false
      t.string :name, null: false
      t.string :prefix, null: false  # "sk_live_" や "sk_test_"
      t.datetime :last_used_at
      t.datetime :expires_at
      t.boolean :active, default: true, null: false
      t.jsonb :permissions, default: {}
 
      t.timestamps
    end
 
    add_index :api_keys, :key_digest, unique: true
    add_index :api_keys, :prefix
  end
end
# app/models/api_key.rb
class ApiKey < ApplicationRecord
  belongs_to :user
 
  before_validation :generate_key, on: :create
 
  validates :name, presence: true
  validates :prefix, inclusion: { in: %w[sk_live_ sk_test_] }
 
  scope :active, -> { where(active: true).where('expires_at IS NULL OR expires_at > ?', Time.current) }
 
  # キーは一度しか表示しない
  attr_reader :raw_key
 
  def self.authenticate(raw_key)
    return nil unless raw_key.present?
 
    # プレフィックスでキーを素早く絞り込む
    prefix = raw_key.first(10)
    api_key = find_by(prefix: prefix)
    return nil unless api_key
 
    # bcryptで検証
    return nil unless BCrypt::Password.new(api_key.key_digest) == raw_key
    return nil unless api_key.active?
 
    api_key.update_column(:last_used_at, Time.current)
    api_key
  end
 
  private
 
  def generate_key
    self.prefix = "sk_live_"
    raw = SecureRandom.urlsafe_base64(32)
    @raw_key = "#{prefix}#{raw}"
    self.key_digest = BCrypt::Password.create(@raw_key)
  end
end

INFO

APIキーはBCryptでハッシュ化して保存します。平文では保存しません。生成時のみ一度だけユーザーに表示し、以降は確認できません。これはStripeやGitHubのAPIキー管理と同じ方針です。

# app/controllers/concerns/api_key_authenticatable.rb
module ApiKeyAuthenticatable
  extend ActiveSupport::Concern
 
  included do
    before_action :authenticate_via_api_key!
  end
 
  private
 
  def authenticate_via_api_key!
    raw_key = extract_api_key
    @current_api_key = ApiKey.authenticate(raw_key)
 
    unless @current_api_key
      render json: {
        error: "Invalid or expired API key",
        code: "UNAUTHORIZED"
      }, status: :unauthorized
    end
  end
 
  def extract_api_key
    # Authorizationヘッダー: "Bearer sk_live_xxx" または "ApiKey sk_live_xxx"
    auth_header = request.headers['Authorization']
    if auth_header&.start_with?('Bearer ', 'ApiKey ')
      auth_header.split(' ', 2).last
    else
      # X-API-Keyヘッダーもサポート
      request.headers['X-API-Key']
    end
  end
 
  def current_user
    @current_api_key&.user
  end
end

Bearer Token(JWTの前提)

ユーザーが直接APIを利用するケースでは、ログイン→トークン発行→トークンでAPI認証という流れを取る。

# app/controllers/api/v1/sessions_controller.rb
module Api
  module V1
    class SessionsController < ApplicationController
      skip_before_action :authenticate_user!
 
      def create
        user = User.find_by(email: params[:email])
 
        # タイミング攻撃を防ぐため、ユーザーが存在しなくても同じ時間がかかるようにする
        if user&.authenticate(params[:password])
          token = generate_token(user)
 
          render json: {
            token: token,
            token_type: "Bearer",
            expires_in: 3600,
            user: UserSerializer.new(user)
          }
        else
          # ユーザー名と認証エラーを区別しない(ユーザー列挙を防ぐ)
          render json: {
            error: "Invalid email or password",
            code: "INVALID_CREDENTIALS"
          }, status: :unauthorized
        end
      end
 
      private
 
      def generate_token(user)
        payload = {
          sub: user.id,
          iat: Time.current.to_i,
          exp: 1.hour.from_now.to_i,
          jti: SecureRandom.uuid  # JWT IDでリプレイ攻撃を防ぐ
        }
        JWT.encode(payload, Rails.application.credentials.jwt_secret, 'HS256')
      end
    end
  end
end

アカウントロックアウト

ブルートフォース攻撃を防ぐために、ログイン失敗回数を制限する。

# app/models/user.rb
class User < ApplicationRecord
  LOCKOUT_ATTEMPTS = 5
  LOCKOUT_DURATION = 30.minutes
 
  def authenticate_with_lockout(password)
    if locked_out?
      return { success: false, error: "Account is locked. Try again after #{locked_until}" }
    end
 
    if authenticate(password)
      reset_failed_attempts!
      { success: true }
    else
      increment_failed_attempts!
      { success: false, error: "Invalid credentials" }
    end
  end
 
  def locked_out?
    failed_attempts >= LOCKOUT_ATTEMPTS &&
      locked_at.present? &&
      locked_at > LOCKOUT_DURATION.ago
  end
 
  def locked_until
    locked_at + LOCKOUT_DURATION
  end
 
  private
 
  def increment_failed_attempts!
    increment!(:failed_attempts)
    update!(locked_at: Time.current) if failed_attempts >= LOCKOUT_ATTEMPTS
  end
 
  def reset_failed_attempts!
    update!(failed_attempts: 0, locked_at: nil)
  end
end
# db/migrate/20240115000002_add_lockout_to_users.rb
class AddLockoutToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :failed_attempts, :integer, default: 0, null: false
    add_column :users, :locked_at, :datetime
 
    add_index :users, :locked_at
  end
end

パスワードポリシーの強化

弱いパスワードは攻撃の入口になる。

# app/models/concerns/password_validatable.rb
module PasswordValidatable
  extend ActiveSupport::Concern
 
  MINIMUM_LENGTH = 12
  # 上位1万件の弱いパスワードリスト
  COMMON_PASSWORDS = Set.new(File.readlines(Rails.root.join('config', 'common_passwords.txt')).map(&:chomp))
 
  included do
    validates :password,
      length: { minimum: MINIMUM_LENGTH },
      format: {
        with: /\A(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
        message: "must include uppercase, lowercase, number, and special character"
      },
      if: :password_required?
 
    validate :password_not_common, if: :password_required?
    validate :password_not_similar_to_email, if: :password_required?
  end
 
  private
 
  def password_not_common
    if COMMON_PASSWORDS.include?(password&.downcase)
      errors.add(:password, "is too common. Please choose a more unique password.")
    end
  end
 
  def password_not_similar_to_email
    if email.present? && password.present?
      if password.downcase.include?(email.split('@').first.downcase)
        errors.add(:password, "should not contain your email address")
      end
    end
  end
 
  def password_required?
    password.present?
  end
end

マルチファクタ認証(MFA)

パスワードだけでは不十分だ。TOTPによる2要素認証を追加する。

# Gemfile
gem 'rotp'        # TOTP生成
gem 'rqrcode'     # QRコード生成
 
# app/models/user.rb(MFA関連)
class User < ApplicationRecord
  def setup_totp
    self.otp_secret = ROTP::Base32.random
    save!
 
    totp = ROTP::TOTP.new(otp_secret, issuer: "StockFlow")
    totp.provisioning_uri(email)  # QRコード用URIを返す
  end
 
  def verify_totp(code)
    return false unless otp_enabled? && otp_secret.present?
 
    totp = ROTP::TOTP.new(otp_secret)
    # drift: 時刻のずれを許容(±30秒)
    totp.verify(code, drift_behind: 30, drift_ahead: 30)
  end
end
# app/controllers/api/v1/sessions_controller.rb(MFA対応版)
def create
  user = User.find_by(email: params[:email])
  result = user&.authenticate_with_lockout(params[:password])
 
  if result&.dig(:success)
    if user.otp_enabled?
      # MFAが必要な場合、一時的なトークンを発行
      temp_token = generate_temp_token(user)
      render json: {
        mfa_required: true,
        temp_token: temp_token
      }, status: :ok
    else
      render json: { token: generate_token(user) }
    end
  else
    render json: { error: result&.dig(:error) || "Invalid credentials" }, status: :unauthorized
  end
end
 
def verify_mfa
  user = User.find_by_temp_token(params[:temp_token])
  return render json: { error: "Invalid token" }, status: :unauthorized unless user
 
  if user.verify_totp(params[:code])
    render json: { token: generate_token(user) }
  else
    render json: { error: "Invalid MFA code" }, status: :unauthorized
  end
end

AWS Cognitoによる認証の委託

規模が大きくなってきたら、認証をCognitoに委託することも選択肢だ。

Loading diagram...
# app/controllers/concerns/cognito_authenticatable.rb
module CognitoAuthenticatable
  extend ActiveSupport::Concern
  require 'jwt'
  require 'net/http'
 
  COGNITO_REGION = ENV['AWS_REGION']
  USER_POOL_ID = ENV['COGNITO_USER_POOL_ID']
  CLIENT_ID = ENV['COGNITO_CLIENT_ID']
 
  included do
    before_action :authenticate_via_cognito!
  end
 
  private
 
  def authenticate_via_cognito!
    token = request.headers['Authorization']&.split(' ')&.last
    return unauthorized! unless token
 
    payload = verify_cognito_token(token)
    return unauthorized! unless payload
 
    @current_user_id = payload['sub']
    @current_user_email = payload['email']
  rescue JWT::DecodeError => e
    Rails.logger.warn("JWT decode error: #{e.message}")
    unauthorized!
  end
 
  def verify_cognito_token(token)
    jwks_url = "https://cognito-idp.#{COGNITO_REGION}.amazonaws.com/#{USER_POOL_ID}/.well-known/jwks.json"
    jwks = fetch_jwks(jwks_url)
 
    JWT.decode(
      token,
      nil,
      true,
      algorithms: ['RS256'],
      jwks: jwks,
      aud: CLIENT_ID,
      iss: "https://cognito-idp.#{COGNITO_REGION}.amazonaws.com/#{USER_POOL_ID}"
    ).first
  end
 
  def fetch_jwks(url)
    Rails.cache.fetch("cognito_jwks", expires_in: 1.hour) do
      response = Net::HTTP.get(URI(url))
      JSON.parse(response)
    end
  end
end

INFO

JWKSのキャッシュは重要です。Cognitoのエンドポイントを毎リクエストごとに叩くとパフォーマンスが低下します。1時間程度のキャッシュが推奨されます。

チェックリスト

  • Basic認証は内部システムのみに限定し、TLSと組み合わせている
  • APIキーはBCryptでハッシュ化して保存している(平文保存は禁止)
  • ログイン失敗のアカウントロックアウトが実装されている(5回失敗で30分ロック等)
  • パスワードポリシーが12文字以上、複雑度要件を満たしている
  • よく使われる弱いパスワードのブロックリストが実装されている
  • TOTPによる多要素認証が提供されている
  • エラーメッセージでユーザー存在を露呈しない(ユーザー列挙防止)
  • タイミング攻撃を防ぐためにsecure_compareを使用している