認証の基礎 — 本人確認の仕組み
「認証と認可の違いを説明できるか?」
CTOのアキラがリョウに尋ねた。セキュリティ強化ミーティングの冒頭だった。
「認証(Authentication)は誰であるかを確認すること、認可(Authorization)は何をしてよいかを決めることです」
「正解だ。今のStockFlowのAPIは、どちらも甘い。まず認証から固めよう」
認証方式の比較
APIの認証方式にはいくつかの選択肢がある。
| 方式 | 用途 | セキュリティ | 利便性 |
|---|---|---|---|
| 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
endAPIキー認証
サービス間通信(外部向け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
endINFO
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
endBearer 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
endAWS Cognitoによる認証の委託
規模が大きくなってきたら、認証をCognitoに委託することも選択肢だ。
# 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
endINFO
JWKSのキャッシュは重要です。Cognitoのエンドポイントを毎リクエストごとに叩くとパフォーマンスが低下します。1時間程度のキャッシュが推奨されます。
チェックリスト
- Basic認証は内部システムのみに限定し、TLSと組み合わせている
- APIキーはBCryptでハッシュ化して保存している(平文保存は禁止)
- ログイン失敗のアカウントロックアウトが実装されている(5回失敗で30分ロック等)
- パスワードポリシーが12文字以上、複雑度要件を満たしている
- よく使われる弱いパスワードのブロックリストが実装されている
- TOTPによる多要素認証が提供されている
- エラーメッセージでユーザー存在を露呈しない(ユーザー列挙防止)
- タイミング攻撃を防ぐためにsecure_compareを使用している