Django OAuth Toolkit

認証ライブラリDjango OAuth ToolkitOAuth2DjangoPythonAPI認証認証サーバーリソースサーバーoauthlibRFC準拠

認証ライブラリ

Django OAuth Toolkit

概要

Django OAuth Toolkitは、DjangoでOAuth2サーバーを構築するための包括的な公式ツールキットです。2025年現在、バージョン3.0.1が最新リリースとなり、RFC 6749完全準拠のOAuth2実装を提供します。優れたOAuthLibライブラリをベースにDjango統合を実現し、認証コード、クライアントクレデンシャル、インプリシット、リフレッシュトークン等の全主要グラントタイプをサポートします。Django REST Framework完全対応、スコープベースアクセス制御、カスタマイズ可能なトークン管理により、エンタープライズグレードのOAuth2認証サーバーとリソースサーバーを構築できます。

詳細

Django OAuth Toolkitは、OAuthLibの高度な機能をDjangoエコシステムと完全統合したライブラリです。アプリケーション管理、クライアント認証、トークン発行、スコープ管理、ユーザー承認等のOAuth2ワークフロー全体を自動化し、開発者は認証ロジックではなくビジネスロジックに集中できます。管理画面からのOAuth2アプリケーション設定、細かなスコープ制御、カスタム認証クラス、セキュリティポリシー設定等の企業要件に対応する柔軟性を提供します。Django 3.2以降とPython 3.8以降をサポートし、モダンなDjango開発環境に最適化されています。

主な特徴

  • RFC 6749完全準拠: すべてのOAuth2標準仕様に準拠した実装
  • 全グラントタイプサポート: Authorization Code、Client Credentials、Implicit、ROPC対応
  • Django統合: Djangoの認証システムと管理画面の完全統合
  • DRF対応: Django REST Frameworkでの認証・認可機能
  • スコープ管理: 細かなアクセス制御とリソース保護
  • カスタマイズ可能: ビジネス要件に応じた柔軟な設定とカスタマイズ

メリット・デメリット

メリット

  • DjangoプロジェクトでOAuth2サーバーの標準実装として確立
  • OAuthLibベースによる堅牢で信頼性の高いRFC準拠実装
  • Django管理画面でのOAuth2アプリケーション管理の簡単さ
  • Django REST Frameworkとの完璧な統合とAPI保護機能
  • 豊富なドキュメントとコミュニティサポートによる学習容易性
  • エンタープライズ環境での実績豊富な安定性と拡張性

デメリット

  • Django専用のため他のフレームワークでは利用不可
  • OAuth2の複雑さにより初学者には学習コストが高い
  • 大規模サービスでのパフォーマンスチューニングが必要
  • OpenID Connect等の最新仕様への対応が限定的
  • カスタマイズ要件が複雑な場合の設定の難しさ
  • トークン管理とセキュリティポリシーの運用負荷

参考ページ

書き方の例

インストールとプロジェクト設定

# Django OAuth Toolkitのインストール
pip install django-oauth-toolkit

# Django REST Frameworkと併用する場合
pip install djangorestframework

# PostgreSQL使用時(推奨)
pip install psycopg2-binary

# 開発時の依存関係
pip install python-dotenv
# settings.py - Django設定
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

# Django OAuth Toolkit設定
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    
    # OAuth2 Provider
    'oauth2_provider',
    
    # Django REST Framework(APIサーバー用)
    'rest_framework',
    
    # アプリケーション
    'accounts',
    'api',
]

MIDDLEWARE = [
    'corsheaders.middleware.CorsMiddleware',
    'oauth2_provider.middleware.OAuth2TokenMiddleware',  # OAuth2ミドルウェア
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'project.urls'

# データベース設定
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DATABASE_NAME', 'oauth_server'),
        'USER': os.getenv('DATABASE_USER', 'postgres'),
        'PASSWORD': os.getenv('DATABASE_PASSWORD', ''),
        'HOST': os.getenv('DATABASE_HOST', 'localhost'),
        'PORT': os.getenv('DATABASE_PORT', '5432'),
    }
}

# Django REST Framework設定
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20
}

# OAuth2 設定
OAUTH2_PROVIDER = {
    # トークン設定
    'ACCESS_TOKEN_EXPIRE_SECONDS': 3600,           # 1時間
    'REFRESH_TOKEN_EXPIRE_SECONDS': 3600 * 24 * 7, # 7日間
    
    # セキュリティ設定
    'AUTHORIZATION_CODE_EXPIRE_SECONDS': 600,      # 10分
    'ROTATE_REFRESH_TOKEN': True,                  # リフレッシュトークンローテーション
    
    # スコープ設定
    'SCOPES': {
        'read': 'Read scope',
        'write': 'Write scope',
        'admin': 'Admin scope',
        'profile': 'Profile access',
        'email': 'Email access',
    },
    'DEFAULT_SCOPES': ['read'],
    
    # アプリケーション設定
    'APPLICATION_MODEL': 'oauth2_provider.Application',
    
    # PKCE設定(推奨)
    'PKCE_REQUIRED': True,
    
    # セキュリティヘッダー
    'OAUTH2_BACKEND_CLASS': 'oauth2_provider.oauth2_backends.JSONOAuthLibCore',
}

# セキュリティ設定
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG

# CORS設定(フロントエンドアプリケーション用)
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # React開発サーバー
    "http://127.0.0.1:3000",
]

CORS_ALLOW_CREDENTIALS = True

# ログ設定
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.FileHandler',
            'filename': 'oauth2.log',
        },
    },
    'loggers': {
        'oauth2_provider': {
            'handlers': ['file'],
            'level': 'INFO',
            'propagate': True,
        },
    },
}

URL設定とエンドポイント

# urls.py - メインURL設定
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    
    # OAuth2エンドポイント
    path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
    
    # API エンドポイント
    path('api/v1/', include('api.urls')),
    
    # 認証関連
    path('auth/', include('accounts.urls')),
]

# api/urls.py - API URL設定
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views

router = DefaultRouter()
router.register(r'users', views.UserViewSet)
router.register(r'posts', views.PostViewSet)

urlpatterns = [
    path('', include(router.urls)),
    path('profile/', views.UserProfileView.as_view(), name='user-profile'),
    path('protected/', views.ProtectedView.as_view(), name='protected'),
]

OAuth2アプリケーション管理

# management/commands/create_oauth_app.py - OAuth2アプリケーション作成コマンド
from django.core.management.base import BaseCommand
from oauth2_provider.models import Application

class Command(BaseCommand):
    help = 'Create OAuth2 application'

    def add_arguments(self, parser):
        parser.add_argument('--name', type=str, help='Application name')
        parser.add_argument('--client-type', type=str, default='confidential', 
                          help='Client type (confidential or public)')
        parser.add_argument('--grant-type', type=str, default='authorization-code',
                          help='Grant type')

    def handle(self, *args, **options):
        application = Application.objects.create(
            name=options['name'] or "My OAuth2 App",
            user=None,  # システムアプリケーション
            client_type=Application.CLIENT_CONFIDENTIAL 
                        if options['client_type'] == 'confidential' 
                        else Application.CLIENT_PUBLIC,
            authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
        )
        
        self.stdout.write(
            self.style.SUCCESS(
                f'Successfully created application:\n'
                f'Client ID: {application.client_id}\n'
                f'Client Secret: {application.client_secret}\n'
                f'Name: {application.name}'
            )
        )

# accounts/models.py - ユーザープロファイル拡張
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    email = models.EmailField(unique=True)
    phone_number = models.CharField(max_length=20, blank=True)
    birth_date = models.DateField(null=True, blank=True)
    profile_image = models.ImageField(upload_to='profiles/', blank=True)
    is_verified = models.BooleanField(default=False)
    
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE)
    bio = models.TextField(max_length=500, blank=True)
    location = models.CharField(max_length=30, blank=True)
    website = models.URLField(blank=True)
    organization = models.CharField(max_length=100, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

API認証とスコープ制御

# api/views.py - OAuth2で保護されたAPIビュー
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.views import APIView
from oauth2_provider.contrib.rest_framework import TokenHasScope
from django.contrib.auth import get_user_model
from .serializers import UserSerializer, PostSerializer
from .models import Post

User = get_user_model()

class UserViewSet(viewsets.ModelViewSet):
    """ユーザー管理API(管理者権限必要)"""
    queryset = User.objects.all()
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAuthenticated, TokenHasScope]
    required_scopes = ['admin']  # 管理者スコープ必須

    def get_queryset(self):
        # スコープに応じたクエリセット制限
        if self.request.auth.scope.has_scope('admin'):
            return User.objects.all()
        return User.objects.filter(id=self.request.user.id)

class PostViewSet(viewsets.ModelViewSet):
    """投稿管理API"""
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [permissions.IsAuthenticated, TokenHasScope]
    
    def get_required_scopes(self):
        # アクションに応じたスコープ要件
        if self.action in ['create', 'update', 'partial_update', 'destroy']:
            return ['write']
        return ['read']
    
    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
    
    @action(detail=False, methods=['get'])
    def my_posts(self, request):
        """ユーザー自身の投稿一覧"""
        posts = Post.objects.filter(author=request.user)
        serializer = self.get_serializer(posts, many=True)
        return Response(serializer.data)

class UserProfileView(APIView):
    """ユーザープロファイルAPI"""
    permission_classes = [permissions.IsAuthenticated, TokenHasScope]
    required_scopes = ['profile']

    def get(self, request):
        serializer = UserSerializer(request.user)
        return Response(serializer.data)

    def put(self, request):
        # プロファイル更新にはwriteスコープも必要
        if not request.auth.scope.has_scope('write'):
            return Response(
                {'error': 'Write scope required for profile updates'}, 
                status=status.HTTP_403_FORBIDDEN
            )
        
        serializer = UserSerializer(request.user, data=request.data, partial=True)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

class ProtectedView(APIView):
    """スコープベースアクセス制御のデモ"""
    permission_classes = [permissions.IsAuthenticated, TokenHasScope]
    required_scopes = ['read']

    def get(self, request):
        content = {
            'message': 'Hello, OAuth2 World!',
            'user': request.user.username,
            'scopes': list(request.auth.scope),
            'application': request.auth.application.name,
        }
        return Response(content)

認証フロー実装

# accounts/views.py - 認証フロー管理
from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from oauth2_provider.models import Application, AccessToken
from oauth2_provider.decorators import protected_resource
import json

def authorize_view(request):
    """OAuth2認証フロー開始ページ"""
    client_id = request.GET.get('client_id')
    redirect_uri = request.GET.get('redirect_uri')
    scope = request.GET.get('scope', '')
    state = request.GET.get('state', '')
    
    try:
        application = Application.objects.get(client_id=client_id)
    except Application.DoesNotExist:
        return render(request, 'oauth2/error.html', {
            'error': 'Invalid client_id'
        })
    
    context = {
        'application': application,
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'scope': scope,
        'state': state,
        'scopes': scope.split(' ') if scope else [],
    }
    
    return render(request, 'oauth2/authorize.html', context)

@login_required
def user_consent_view(request):
    """ユーザー同意確認ページ"""
    if request.method == 'POST':
        # ユーザーが同意した場合のOAuth2フロー継続
        return redirect('/o/authorize/?' + request.META['QUERY_STRING'])
    
    return render(request, 'oauth2/consent.html')

@require_http_methods(["POST"])
def revoke_token_view(request):
    """トークン無効化API"""
    try:
        data = json.loads(request.body)
        token = data.get('token')
        
        if not token:
            return JsonResponse({'error': 'Token required'}, status=400)
        
        try:
            access_token = AccessToken.objects.get(token=token)
            access_token.delete()
            return JsonResponse({'message': 'Token revoked successfully'})
        except AccessToken.DoesNotExist:
            return JsonResponse({'error': 'Invalid token'}, status=400)
            
    except json.JSONDecodeError:
        return JsonResponse({'error': 'Invalid JSON'}, status=400)

@protected_resource(scopes=['profile'])
def protected_api_view(request):
    """デコレーターベースのAPI保護"""
    return JsonResponse({
        'message': 'This is a protected resource',
        'user_id': request.user.id,
        'username': request.user.username,
    })

クライアント側の認証実装

# oauth_client.py - OAuth2クライアント実装例
import requests
import urllib.parse
from django.conf import settings

class OAuth2Client:
    def __init__(self, client_id, client_secret, base_url):
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = base_url
        self.token_url = f"{base_url}/o/token/"
        self.authorize_url = f"{base_url}/o/authorize/"
    
    def get_authorization_url(self, redirect_uri, scope=None, state=None):
        """認証URL生成"""
        params = {
            'client_id': self.client_id,
            'response_type': 'code',
            'redirect_uri': redirect_uri,
        }
        
        if scope:
            params['scope'] = scope
        if state:
            params['state'] = state
            
        return f"{self.authorize_url}?{urllib.parse.urlencode(params)}"
    
    def exchange_code_for_token(self, code, redirect_uri):
        """認証コードをトークンに交換"""
        data = {
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': redirect_uri,
            'client_id': self.client_id,
            'client_secret': self.client_secret,
        }
        
        response = requests.post(self.token_url, data=data)
        response.raise_for_status()
        return response.json()
    
    def refresh_access_token(self, refresh_token):
        """アクセストークンのリフレッシュ"""
        data = {
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token,
            'client_id': self.client_id,
            'client_secret': self.client_secret,
        }
        
        response = requests.post(self.token_url, data=data)
        response.raise_for_status()
        return response.json()
    
    def get_client_credentials_token(self, scope=None):
        """クライアントクレデンシャルグラントでトークン取得"""
        data = {
            'grant_type': 'client_credentials',
            'client_id': self.client_id,
            'client_secret': self.client_secret,
        }
        
        if scope:
            data['scope'] = scope
            
        response = requests.post(self.token_url, data=data)
        response.raise_for_status()
        return response.json()
    
    def make_authenticated_request(self, access_token, url, method='GET', data=None):
        """認証ヘッダー付きAPIリクエスト"""
        headers = {
            'Authorization': f'Bearer {access_token}',
            'Content-Type': 'application/json',
        }
        
        if method.upper() == 'GET':
            response = requests.get(url, headers=headers)
        elif method.upper() == 'POST':
            response = requests.post(url, json=data, headers=headers)
        elif method.upper() == 'PUT':
            response = requests.put(url, json=data, headers=headers)
        elif method.upper() == 'DELETE':
            response = requests.delete(url, headers=headers)
        else:
            raise ValueError(f"Unsupported HTTP method: {method}")
        
        return response

# 使用例
if __name__ == "__main__":
    client = OAuth2Client(
        client_id='your-client-id',
        client_secret='your-client-secret',
        base_url='http://localhost:8000'
    )
    
    # 認証URL生成
    auth_url = client.get_authorization_url(
        redirect_uri='http://localhost:3000/callback',
        scope='read write profile'
    )
    print(f"認証URL: {auth_url}")
    
    # トークン交換(認証後)
    # token_data = client.exchange_code_for_token(
    #     code='received-auth-code',
    #     redirect_uri='http://localhost:3000/callback'
    # )
    
    # APIアクセス
    # response = client.make_authenticated_request(
    #     access_token=token_data['access_token'],
    #     url='http://localhost:8000/api/v1/profile/'
    # )

カスタム認証バックエンドとテンプレート

<!-- templates/oauth2/authorize.html - 認証同意ページ -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>認証リクエスト - {{ application.name }}</title>
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="card">
                    <div class="card-header">
                        <h4 class="mb-0">認証リクエスト</h4>
                    </div>
                    <div class="card-body">
                        <div class="text-center mb-4">
                            <h5>{{ application.name }}</h5>
                            <p class="text-muted">があなたのアカウントへのアクセスを要求しています</p>
                        </div>
                        
                        {% if scopes %}
                        <div class="mb-4">
                            <h6>要求されるアクセス権限:</h6>
                            <ul class="list-group">
                                {% for scope in scopes %}
                                <li class="list-group-item">
                                    <strong>{{ scope }}</strong>
                                    {% if scope == 'read' %}
                                        - 基本情報の読み取り
                                    {% elif scope == 'write' %}
                                        - データの作成・更新
                                    {% elif scope == 'profile' %}
                                        - プロファイル情報へのアクセス
                                    {% elif scope == 'email' %}
                                        - メールアドレスへのアクセス
                                    {% elif scope == 'admin' %}
                                        - 管理者権限
                                    {% endif %}
                                </li>
                                {% endfor %}
                            </ul>
                        </div>
                        {% endif %}
                        
                        <form method="post" action="/o/authorize/">
                            {% csrf_token %}
                            <input type="hidden" name="client_id" value="{{ client_id }}">
                            <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
                            <input type="hidden" name="scope" value="{{ scope }}">
                            <input type="hidden" name="state" value="{{ state }}">
                            <input type="hidden" name="response_type" value="code">
                            
                            <div class="d-grid gap-2">
                                <button type="submit" name="allow" class="btn btn-primary">
                                    アクセスを許可
                                </button>
                                <button type="submit" name="deny" class="btn btn-outline-secondary">
                                    拒否
                                </button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>
</html>