認証とアクセス制御 — APIキーからOAuth2まで
セキュリティインシデントの予感
「ナツミさん、ちょっといいですか」
セキュリティ担当の吉田さんが神妙な顔でやってきた。
「今の実装、認証が before_action :authenticate_user! だけですよね。JWTトークンの検証ロジック、どこにあります?」
ナツミは答えられなかった。急いで実装した認証は、実はトークンの有効期限すら検証していなかった。
「本番前に直しましょう。認証の設計は一番重要ですから」
認証の種類と選択基準
APIの認証には複数のアプローチがある。
Loading diagram...
Livlyの要件は:
- モバイルアプリ(iOS/Android)からのアクセス → JWT
- 管理ツールからのサーバー間通信 → APIキー
- 将来の外部連携(不動産ポータルへの物件連携)→ OAuth2
JWT認証の実装(devise_token_auth)
インストール
# Gemfile
gem 'devise'
gem 'devise_token_auth'rails generate devise:install
rails generate devise_token_auth:install User auth
rails db:migrateルーティング
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
mount_devise_token_auth_for 'User', at: 'auth', controllers: {
sessions: 'api/v1/auth/sessions',
registrations: 'api/v1/auth/registrations',
passwords: 'api/v1/auth/passwords'
}
resources :properties
end
end
endカスタムコントローラ
# app/controllers/api/v1/auth/sessions_controller.rb
module Api
module V1
module Auth
class SessionsController < DeviseTokenAuth::SessionsController
private
def render_create_success
render json: {
data: {
id: @resource.id,
email: @resource.email,
name: @resource.name,
avatar_url: @resource.avatar_url
}
}
end
def render_create_error_bad_credentials
render json: {
error: 'メールアドレスまたはパスワードが正しくありません'
}, status: :unauthorized
end
end
end
end
end認証フロー
Loading diagram...
クライアント(iOS)でのリクエスト
// iOSアプリ側のリクエスト例
var request = URLRequest(url: URL(string: "https://api.livly.jp/api/v1/properties")!)
request.setValue(accessToken, forHTTPHeaderField: "access-token")
request.setValue(client, forHTTPHeaderField: "client")
request.setValue(uid, forHTTPHeaderField: "uid")カスタムJWT実装(よりシンプルなアプローチ)
devise_token_auth が重すぎる場合は、JWTを直接実装する。
# Gemfile
gem 'jwt'# app/services/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base
EXPIRATION = 24.hours
def self.encode(payload)
payload[:exp] = EXPIRATION.from_now.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })
HashWithIndifferentAccess.new(decoded[0])
rescue JWT::ExpiredSignature
raise AuthenticationError, 'トークンの有効期限が切れています'
rescue JWT::DecodeError
raise AuthenticationError, '無効なトークンです'
end
end# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ActionController::API
before_action :authenticate_user!
private
def current_user
@current_user ||= begin
token = request.headers['Authorization']&.split(' ')&.last
raise AuthenticationError, 'トークンがありません' unless token
payload = JsonWebToken.decode(token)
User.find(payload[:user_id])
rescue ActiveRecord::RecordNotFound
raise AuthenticationError, 'ユーザーが見つかりません'
end
end
def authenticate_user!
current_user
rescue AuthenticationError => e
render json: { error: e.message }, status: :unauthorized
end
end
end
end# app/controllers/api/v1/auth/sessions_controller.rb
module Api
module V1
module Auth
class SessionsController < ActionController::API
def create
user = User.find_by(email: session_params[:email])
if user&.authenticate(session_params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: {
token: token,
expires_at: 24.hours.from_now.iso8601,
user: { id: user.id, email: user.email, name: user.name }
}
else
render json: { error: 'メールアドレスまたはパスワードが正しくありません' },
status: :unauthorized
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
end
end
endアクセス制御(認可)
認証(Authentication)と認可(Authorization)は別物。
- 認証:「あなたは誰ですか?」
- 認可:「あなたはこれをして良いですか?」
# Punditを使った認可
gem 'pundit'# app/policies/property_policy.rb
class PropertyPolicy < ApplicationPolicy
def show?
# 公開物件は誰でも、非公開は所有者のみ
record.published? || record.user == user
end
def update?
record.user == user
end
def destroy?
record.user == user || user.admin?
end
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.where(published: true).or(scope.where(user: user))
end
end
end
end# app/controllers/api/v1/properties_controller.rb
module Api
module V1
class PropertiesController < Api::V1::BaseController
include Pundit::Authorization
def show
@property = Property.find(params[:id])
authorize @property # PolicyのshowをチェックまするかPundit例外
render json: PropertySerializer.new(@property).serializable_hash
end
def update
@property = Property.find(params[:id])
authorize @property # updateをチェック
if @property.update(property_params)
render json: PropertySerializer.new(@property).serializable_hash
else
render json: { errors: @property.errors.full_messages },
status: :unprocessable_entity
end
end
rescue_from Pundit::NotAuthorizedError do
render json: { error: 'この操作を行う権限がありません' }, status: :forbidden
end
end
end
endOAuth2 の実装(Doorkeeper)
外部サービスからのAPI連携にはOAuth2が標準。
# 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 || warden.authenticate!(scope: :user)
end
# 許可するグラントタイプ
grant_flows %w[authorization_code client_credentials refresh_token]
# アクセストークンの有効期限
access_token_expires_in 2.hours
# リフレッシュトークンの有効期限
refresh_token_expires_in 30.days
# スコープ(権限の粒度)
default_scopes :read
optional_scopes :write, :admin
# クライアント側でのシークレット認証を強制
force_ssl_in_redirect_uri Rails.env.production?
end# config/routes.rb
use_doorkeeper do
# デフォルトのビューは必要に応じてカスタマイズ
end# APIコントローラでDoorkeeperを使った認証
module Api
module V1
class BaseController < ActionController::API
before_action :doorkeeper_authorize!
private
def current_user
@current_user ||= User.find(doorkeeper_token.resource_owner_id)
end
# スコープ制限
def require_write_scope!
unless doorkeeper_token.scopes.include?('write')
render json: { error: 'writeスコープが必要です' }, status: :forbidden
end
end
end
end
endOAuth2フロー
Loading diagram...
リフレッシュトークンの実装
アクセストークンは短命(1〜2時間)、リフレッシュトークンで再取得する。
# カスタムJWT実装でのリフレッシュトークン
class TokenService
def self.issue(user)
access_token = JsonWebToken.encode(
{ user_id: user.id, type: 'access' },
expires_in: 1.hour
)
refresh_token = SecureRandom.hex(32)
user.update!(
refresh_token: refresh_token,
refresh_token_expires_at: 30.days.from_now
)
{ access_token: access_token, refresh_token: refresh_token }
end
def self.refresh(refresh_token_value)
user = User.find_by(refresh_token: refresh_token_value)
raise AuthenticationError, '無効なリフレッシュトークン' unless user
raise AuthenticationError, 'リフレッシュトークンが期限切れ' if user.refresh_token_expires_at < Time.current
issue(user)
end
endWARNING
リフレッシュトークンはDBに保存する。JWTはステートレスで失効できないため、リフレッシュトークンはデータベースで管理し、ログアウト時や不正使用時に削除できるようにする。
AWS API Gatewayでの認証
本番環境ではAWS API Gatewayで認証の一部を担わせることができる。
# API Gatewayのリソースポリシー例(Terraformイメージ)
resource "aws_api_gateway_authorizer" "jwt_authorizer" {
name = "JWTAuthorizer"
rest_api_id = aws_api_gateway_rest_api.livly.id
authorizer_uri = aws_lambda_function.jwt_validator.invoke_arn
authorizer_credentials = aws_iam_role.api_gateway_auth.arn
type = "TOKEN"
identity_source = "method.request.header.Authorization"
}Loading diagram...
まとめ
- モバイルアプリ → JWT(
devise_token_authまたは カスタム実装) - サーバー間通信 → APIキー(シンプルで管理しやすい)
- 外部連携 → OAuth2(Doorkeeper gem)
- 認可はPunditでポリシークラスに集約する
- リフレッシュトークンはDBで管理し、失効を可能にする
- AWS API GatewayでJWT検証をアプリの手前で行うことで負荷を軽減できる
次章では、エラーが起きたときに開発者に優しいエラーレスポンスを返す設計を学ぶ。