KR_Sanic - somaz94/python-study GitHub Wiki

Python Sanic ๊ฐœ๋… ์ •๋ฆฌ


1๏ธโƒฃ Sanic ๊ธฐ์ดˆ

Sanic์€ Python 3.7+ ๊ธฐ๋ฐ˜์˜ ๊ณ ์„ฑ๋Šฅ ๋น„๋™๊ธฐ ์›น ํ”„๋ ˆ์ž„์›Œํฌ๋กœ, ๋น ๋ฅธ HTTP ์‘๋‹ต ์†๋„๋ฅผ ์ œ๊ณตํ•˜๋ฉฐ uvloop์™€ httptools๋ฅผ ํ™œ์šฉํ•˜์—ฌ Node.js ๋ฐ Go์™€ ๋น„์Šทํ•œ ์„ฑ๋Šฅ์„ ๋ชฉํ‘œ๋กœ ํ•œ๋‹ค.

from sanic import Sanic
from sanic.response import json, text, html
from sanic.request import Request
from sanic import exceptions
import logging
import asyncio
from typing import Dict, Any, List, Optional, Union

# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ƒ์„ฑ
app = Sanic("MyApplication")

# ๊ธฐ๋ณธ ์„ค์ •
app.config.FALLBACK_ERROR_FORMAT = "json"  # ์˜ค๋ฅ˜ ์‘๋‹ต ํ˜•์‹
app.config.ACCESS_LOG = True  # ์ ‘๊ทผ ๋กœ๊ทธ ํ™œ์„ฑํ™”
app.config.FORWARDED_SECRET = "YOUR_SECRET_KEY"  # ํ”„๋ก์‹œ ๋ณด์•ˆ

# ๋กœ๊ฑฐ ์„ค์ •
logger = logging.getLogger('sanic')

# ๊ธฐ๋ณธ ๋ผ์šฐํŠธ ํ•ธ๋“ค๋Ÿฌ
@app.route("/", methods=["GET"])
async def index(request: Request) -> json:
    """
    ๊ธฐ๋ณธ ์ธ๋ฑ์Šค ํŽ˜์ด์ง€ ํ•ธ๋“ค๋Ÿฌ
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
        
    Returns:
        JSON ์‘๋‹ต
    """
    return json({
        "message": "Welcome to Sanic API",
        "status": "success",
        "version": "1.0.0"
    })

# ๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์‚ฌ์šฉํ•œ ๋ผ์šฐํŠธ
@app.route("/users/<user_id:int>", methods=["GET"])
async def get_user(request: Request, user_id: int) -> json:
    """
    ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ํ•ธ๋“ค๋Ÿฌ
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
        user_id: ์‚ฌ์šฉ์ž ID
        
    Returns:
        ์‚ฌ์šฉ์ž ์ •๋ณด JSON ์‘๋‹ต
    """
    # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์กฐํšŒ ๋กœ์ง (์˜ˆ์‹œ)
    await asyncio.sleep(0.01)  # ๋น„๋™๊ธฐ ์ž‘์—… ์‹œ๋ฎฌ๋ ˆ์ด์…˜
    
    # ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ 404 ์˜ค๋ฅ˜ ๋ฐœ์ƒ
    if user_id > 1000:
        raise exceptions.NotFound(f"์‚ฌ์šฉ์ž ID {user_id}๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")
    
    return json({
        "user_id": user_id,
        "name": f"User {user_id}",
        "email": f"user{user_id}@example.com",
        "created_at": "2023-01-01T00:00:00Z"
    })

# POST ์š”์ฒญ ์ฒ˜๋ฆฌ
@app.post("/users")
async def create_user(request: Request) -> json:
    """
    ์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ ํ•ธ๋“ค๋Ÿฌ
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
        
    Returns:
        ์ƒ์„ฑ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด JSON ์‘๋‹ต
    """
    try:
        # ์š”์ฒญ ๋ณธ๋ฌธ์—์„œ JSON ๋ฐ์ดํ„ฐ ํŒŒ์‹ฑ
        data = request.json
        
        # ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ
        if not data or 'name' not in data or 'email' not in data:
            raise exceptions.BadRequest("์ด๋ฆ„๊ณผ ์ด๋ฉ”์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
            
        # ์‚ฌ์šฉ์ž ์ƒ์„ฑ ๋กœ์ง (์˜ˆ์‹œ)
        await asyncio.sleep(0.05)  # ๋น„๋™๊ธฐ ์ž‘์—… ์‹œ๋ฎฌ๋ ˆ์ด์…˜
        
        # ์ƒ์„ฑ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด ๋ฐ˜ํ™˜
        new_user = {
            "user_id": 1001,  # ์˜ˆ์‹œ ID
            "name": data['name'],
            "email": data['email'],
            "created_at": "2023-01-01T00:00:00Z"
        }
        
        logger.info(f"์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ: {new_user['user_id']}")
        return json({"status": "created", "user": new_user}, status=201)
        
    except Exception as e:
        logger.error(f"์‚ฌ์šฉ์ž ์ƒ์„ฑ ์˜ค๋ฅ˜: {str(e)}")
        return json({"error": str(e)}, status=500)

# ์˜ค๋ฅ˜ ํ•ธ๋“ค๋Ÿฌ
@app.exception(exceptions.NotFound)
async def not_found(request: Request, exception: exceptions.NotFound) -> json:
    """
    404 ์˜ค๋ฅ˜ ํ•ธ๋“ค๋Ÿฌ
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
        exception: ๋ฐœ์ƒํ•œ ์˜ˆ์™ธ
        
    Returns:
        ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ JSON ์‘๋‹ต
    """
    return json({
        "error": "not_found",
        "message": str(exception)
    }, status=404)

# ๋‹ค์–‘ํ•œ ์‘๋‹ต ํƒ€์ž…
@app.route("/responses")
async def response_types(request: Request):
    """๋‹ค์–‘ํ•œ ์‘๋‹ต ํƒ€์ž… ์˜ˆ์ œ"""
    response_type = request.args.get('type', 'json')
    
    if response_type == 'text':
        return text("Plain text response")
    elif response_type == 'html':
        return html("<h1>HTML Response</h1>")
    else:
        return json({"type": "json", "message": "JSON Response"})

# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹คํ–‰
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=True)

โœ… ํŠน์ง•:

  • ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•œ ๊ณ ์„ฑ๋Šฅ HTTP ์„œ๋น„์Šค
  • ํƒ€์ž… ํžŒํŒ…์„ ํ†ตํ•œ ์ฝ”๋“œ ๊ฐ€๋…์„ฑ ๋ฐ IDE ์ง€์› ํ–ฅ์ƒ
  • ๋‹ค์–‘ํ•œ ๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž… ์ง€์›(int, string, uuid, float, path)
  • ๋‹ค์–‘ํ•œ ์‘๋‹ต ํ˜•์‹(JSON, HTML, ํ…์ŠคํŠธ, ํŒŒ์ผ ๋“ฑ)
  • ๋‚ด์žฅ๋œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋ฉ”์ปค๋‹ˆ์ฆ˜
  • ์ž๋™ ๋ฆฌ๋กœ๋“œ ๊ธฐ๋Šฅ์„ ํ†ตํ•œ ๊ฐœ๋ฐœ ์ƒ์‚ฐ์„ฑ ํ–ฅ์ƒ
  • HTTP/2 ๋ฐ ์›น์†Œ์ผ“ ์ง€์›
  • ๋ฏธ๋“ค์›จ์–ด ๋ฐ ๋ฆฌ์Šค๋„ˆ๋ฅผ ํ†ตํ•œ ํ™•์žฅ์„ฑ
  • uvloop ํ™œ์šฉ์œผ๋กœ ํ‘œ์ค€ asyncio๋ณด๋‹ค 2๋ฐฐ ์ด์ƒ ๋น ๋ฅธ ์„ฑ๋Šฅ
  • ๊ตฌ์กฐํ™”๋œ ๋กœ๊น… ์‹œ์Šคํ…œ


2๏ธโƒฃ ๋ฏธ๋“ค์›จ์–ด์™€ ๋ฆฌ์Šค๋„ˆ

Sanic์€ ์š”์ฒญ ์ฒ˜๋ฆฌ ์ „ํ›„์— ์ฝ”๋“œ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฏธ๋“ค์›จ์–ด์™€ ์„œ๋ฒ„ ์ƒ๋ช…์ฃผ๊ธฐ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ๋ฆฌ์Šค๋„ˆ๋ฅผ ์ œ๊ณตํ•˜์—ฌ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ํ™•์žฅ์„ฑ๊ณผ ๊ธฐ๋Šฅ์„ฑ์„ ๋†’์ธ๋‹ค.

from sanic import Sanic, Request, response
from sanic.response import json
import uuid
import time
import logging
import asyncio
import aioredis
from datetime import datetime
from typing import Dict, Any, Optional, Callable

app = Sanic("MiddlewareApp")
logger = logging.getLogger('sanic.middleware')

# ์š”์ฒญ ๋ฏธ๋“ค์›จ์–ด ์ •์˜
@app.middleware('request')
async def add_request_id(request: Request):
    """
    ๊ฐ ์š”์ฒญ์— ๊ณ ์œ  ID ์ถ”๊ฐ€
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
    """
    request_id = str(uuid.uuid4())
    request.ctx.request_id = request_id
    logger.info(f"Request started: {request_id}")
    
    # ์š”์ฒญ ์‹œ์ž‘ ์‹œ๊ฐ„ ๊ธฐ๋ก
    request.ctx.start_time = time.time()

# ๋‹ค๋ฅธ ์š”์ฒญ ๋ฏธ๋“ค์›จ์–ด - ์ธ์ฆ ๊ฒ€์‚ฌ
@app.middleware('request')
async def check_auth(request: Request):
    """
    ์ธ์ฆ ํ—ค๋” ๊ฒ€์‚ฌ ๋ฏธ๋“ค์›จ์–ด
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
    """
    # ์ธ์ฆ์ด ํ•„์š” ์—†๋Š” ๊ฒฝ๋กœ๋Š” ๊ฑด๋„ˆ๋œ€
    if request.path in ['/login', '/register', '/health']:
        return
    
    auth_header = request.headers.get('Authorization')
    if not auth_header or not auth_header.startswith('Bearer '):
        # ์ด ๋ผ์ธ์€ ๋ฏธ๋“ค์›จ์–ด์—์„œ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ ์š”์ฒญ ์ฒ˜๋ฆฌ๊ฐ€ ์ค‘๋‹จ๋จ
        return json({'error': 'unauthorized', 'message': '์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'}, status=401)
    
    # ์‹ค์ œ ๊ตฌํ˜„์—์„œ๋Š” ํ† ํฐ ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€
    token = auth_header.split(' ')[1]
    # ์˜ˆ: validate_token(token)
    
    # ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ์š”์ฒญ ์ปจํ…์ŠคํŠธ์— ์ €์žฅ
    request.ctx.user = {'id': 1, 'role': 'user'}  # ์˜ˆ์‹œ ๋ฐ์ดํ„ฐ

# ์‘๋‹ต ๋ฏธ๋“ค์›จ์–ด ์ •์˜
@app.middleware('response')
async def add_response_headers(request: Request, response):
    """
    ์‘๋‹ต ํ—ค๋” ์ถ”๊ฐ€ ๋ฏธ๋“ค์›จ์–ด
    
    Args:
        request: Sanic ์š”์ฒญ ๊ฐ์ฒด
        response: Sanic ์‘๋‹ต ๊ฐ์ฒด
    """
    # ์š”์ฒญ ID ํ—ค๋” ์ถ”๊ฐ€
    if hasattr(request.ctx, 'request_id'):
        response.headers['X-Request-ID'] = request.ctx.request_id
    
    # ์‘๋‹ต ์‹œ๊ฐ„ ๊ธฐ๋ก ๋ฐ ํ—ค๋” ์ถ”๊ฐ€
    if hasattr(request.ctx, 'start_time'):
        duration = time.time() - request.ctx.start_time
        response.headers['X-Response-Time'] = f"{duration:.6f}s"
        logger.info(f"Request {request.ctx.request_id} completed in {duration:.6f}s")
    
    # ๋ณด์•ˆ ํ—ค๋” ์ถ”๊ฐ€
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-XSS-Protection'] = '1; mode=block'

# ์ปค์Šคํ…€ ๋ฏธ๋“ค์›จ์–ด ํ•จ์ˆ˜ ์ƒ์„ฑ
def rate_limit_middleware(limit: int = 10, window: int = 60):
    """
    ์†๋„ ์ œํ•œ ๋ฏธ๋“ค์›จ์–ด ํŒฉํ† ๋ฆฌ
    
    Args:
        limit: ์‹œ๊ฐ„ ์ฐฝ ๋‚ด ์ตœ๋Œ€ ์š”์ฒญ ์ˆ˜
        window: ์‹œ๊ฐ„ ์ฐฝ (์ดˆ)
        
    Returns:
        ๋ฏธ๋“ค์›จ์–ด ํ•จ์ˆ˜
    """
    request_counts = {}
    
    @app.middleware('request')
    async def rate_limit(request: Request):
        # ํด๋ผ์ด์–ธํŠธ ์‹๋ณ„
        client_ip = request.client_ip
        current_time = int(time.time())
        
        # ํ˜„์žฌ ํด๋ผ์ด์–ธํŠธ์˜ ์š”์ฒญ ๊ธฐ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ
        if client_ip not in request_counts:
            request_counts[client_ip] = []
        
        # ์‹œ๊ฐ„ ์ฐฝ ์™ธ์˜ ์š”์ฒญ ์ œ๊ฑฐ
        request_counts[client_ip] = [timestamp for timestamp in request_counts[client_ip] 
                               if timestamp > current_time - window]
        
        # ์š”์ฒญ ์ˆ˜ ํ™•์ธ
        if len(request_counts[client_ip]) >= limit:
            return json({
                'error': 'rate_limit_exceeded',
                'message': f'์š”์ฒญ ํ•œ๋„ ์ดˆ๊ณผ, {window}์ดˆ ํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”'
            }, status=429)
        
        # ํ˜„์žฌ ์š”์ฒญ ์‹œ๊ฐ„ ๊ธฐ๋ก
        request_counts[client_ip].append(current_time)
    
    return rate_limit

# ์„œ๋ฒ„ ์‹œ์ž‘ ์ „ ๋ฆฌ์Šค๋„ˆ
@app.listener('before_server_start')
async def setup_db(app: Sanic, loop):
    """
    ์„œ๋ฒ„ ์‹œ์ž‘ ์ „ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ค์ •
    
    Args:
        app: Sanic ์•ฑ ์ธ์Šคํ„ด์Šค
        loop: ์ด๋ฒคํŠธ ๋ฃจํ”„
    """
    logger.info("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์„ค์ • ์ค‘...")
    # ์˜ˆ์‹œ: PostgreSQL ์—ฐ๊ฒฐ ์„ค์ •
    # import asyncpg
    # app.ctx.db_pool = await asyncpg.create_pool(
    #     user='user', password='password',
    #     database='database', host='localhost'
    # )
    
    # ์˜ˆ์‹œ: Redis ์—ฐ๊ฒฐ ์„ค์ •
    try:
        app.ctx.redis = await aioredis.from_url(
            "redis://localhost", encoding="utf-8", decode_responses=True
        )
        logger.info("Redis ์—ฐ๊ฒฐ ์„ฑ๊ณต")
    except Exception as e:
        logger.error(f"Redis ์—ฐ๊ฒฐ ์‹คํŒจ: {e}")
    
    logger.info("์„œ๋ฒ„ ์‹œ์ž‘ ์ค€๋น„ ์™„๋ฃŒ")

# ์„œ๋ฒ„ ์‹œ์ž‘ ํ›„ ๋ฆฌ์Šค๋„ˆ
@app.listener('after_server_start')
async def notify_server_started(app: Sanic, loop):
    """
    ์„œ๋ฒ„ ์‹œ์ž‘ ์•Œ๋ฆผ
    
    Args:
        app: Sanic ์•ฑ ์ธ์Šคํ„ด์Šค
        loop: ์ด๋ฒคํŠธ ๋ฃจํ”„
    """
    logger.info(f"์„œ๋ฒ„๊ฐ€ ์‹œ์ž‘๋จ! http://{app.config.HOST}:{app.config.PORT}")
    
    # ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์„œ๋ฒ„ ์‹œ์ž‘ ์•Œ๋ฆผ ๋˜๋Š” ์ดˆ๊ธฐ ์ž‘์—… ์ˆ˜ํ–‰
    # ์˜ˆ: ์บ์‹œ ์›Œ๋ฐ์—…, ์ดˆ๊ธฐ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ๋“ฑ
    async def warmup_cache():
        logger.info("์บ์‹œ ์›Œ๋ฐ์—… ์ค‘...")
        await asyncio.sleep(1)
        # ์บ์‹œ ์›Œ๋ฐ์—… ๋กœ์ง
        logger.info("์บ์‹œ ์›Œ๋ฐ์—… ์™„๋ฃŒ")
    
    # ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—…์œผ๋กœ ์‹คํ–‰
    asyncio.create_task(warmup_cache())

# ์„œ๋ฒ„ ์ค‘์ง€ ์ „ ๋ฆฌ์Šค๋„ˆ
@app.listener('before_server_stop')
async def notify_server_stopping(app: Sanic, loop):
    """
    ์„œ๋ฒ„ ์ค‘์ง€ ์•Œ๋ฆผ
    
    Args:
        app: Sanic ์•ฑ ์ธ์Šคํ„ด์Šค
        loop: ์ด๋ฒคํŠธ ๋ฃจํ”„
    """
    logger.info("์„œ๋ฒ„ ์ค‘์ง€ ์ค‘...")
    # ์ง„ํ–‰ ์ค‘์ธ ์ž‘์—… ์™„๋ฃŒ ๋˜๋Š” ์ •๋ฆฌ

# ์„œ๋ฒ„ ์ค‘์ง€ ํ›„ ๋ฆฌ์Šค๋„ˆ
@app.listener('after_server_stop')
async def close_db(app: Sanic, loop):
    """
    ์„œ๋ฒ„ ์ค‘์ง€ ํ›„ ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ
    
    Args:
        app: Sanic ์•ฑ ์ธ์Šคํ„ด์Šค
        loop: ์ด๋ฒคํŠธ ๋ฃจํ”„
    """
    logger.info("๋ฆฌ์†Œ์Šค ์ •๋ฆฌ ์ค‘...")
    
    # Redis ์—ฐ๊ฒฐ ์ข…๋ฃŒ
    if hasattr(app.ctx, 'redis'):
        await app.ctx.redis.close()
        logger.info("Redis ์—ฐ๊ฒฐ ์ข…๋ฃŒ๋จ")
    
    # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ’€ ์ข…๋ฃŒ
    if hasattr(app.ctx, 'db_pool'):
        await app.ctx.db_pool.close()
        logger.info("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ํ’€ ์ข…๋ฃŒ๋จ")
    
    logger.info("๋ชจ๋“  ๋ฆฌ์†Œ์Šค๊ฐ€ ์ •๋ฆฌ๋จ")

# ๋ฆฌ์Šค๋„ˆ๋ฅผ ํ•จ์ˆ˜๋กœ ๋“ฑ๋กํ•˜๋Š” ๋ฐฉ๋ฒ•
async def setup_something(app: Sanic, loop):
    logger.info("์ถ”๊ฐ€ ์„ค์ • ์‹คํ–‰ ์ค‘...")
    app.ctx.start_time = datetime.now()

# ๋‚˜์ค‘์— ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก
app.register_listener(setup_something, 'before_server_start')

# ํ—ฌ์Šค์ฒดํฌ ๊ฒฝ๋กœ
@app.route('/health')
async def health_check(request: Request):
    """์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ ์—”๋“œํฌ์ธํŠธ"""
    uptime = None
    if hasattr(app.ctx, 'start_time'):
        uptime = (datetime.now() - app.ctx.start_time).total_seconds()
    
    return json({
        'status': 'healthy',
        'version': '1.0.0',
        'uptime_seconds': uptime
    })

โœ… ํŠน์ง•:

  • ์š”์ฒญ/์‘๋‹ต ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๊ฐ•๋ ฅํ•œ ๋ฏธ๋“ค์›จ์–ด ์ฒด์ธ
  • ์„œ๋ฒ„ ์ƒ๋ช…์ฃผ๊ธฐ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ ๋ฆฌ์Šค๋„ˆ ์‹œ์Šคํ…œ
  • ๋ฏธ๋“ค์›จ์–ด๋ฅผ ํ™œ์šฉํ•œ ๊ณตํ†ต ๊ธฐ๋Šฅ ๊ตฌํ˜„ (์ธ์ฆ, ๋กœ๊น…, ์†๋„ ์ œํ•œ ๋“ฑ)
  • ์š”์ฒญ/์‘๋‹ต ์ปจํ…์ŠคํŠธ๋ฅผ ํ†ตํ•œ ๋ฐ์ดํ„ฐ ๊ณต์œ 
  • ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ๊ด€๋ฆฌ
  • ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ž‘์—… ์‹คํ–‰ ๊ธฐ๋Šฅ
  • ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ƒํƒœ ๋ฐ ๋ฆฌ์†Œ์Šค ๊ด€๋ฆฌ
  • ์š”์ฒญ๋ณ„ ๊ณ ์œ  ID ๋ฐ ์ถ”์  ๊ธฐ๋Šฅ
  • ๋ณด์•ˆ ๊ด€๋ จ ํ—ค๋” ์ž๋™ ์ถ”๊ฐ€
  • ์š”์ฒญ ์ฒ˜๋ฆฌ ์„ฑ๋Šฅ ์ธก์ • ๋ฐ ๋ชจ๋‹ˆํ„ฐ๋ง
  • ์šฐ์•„ํ•œ ์ข…๋ฃŒ ๋ฐ ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ ์ง€์›


3๏ธโƒฃ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ์™€ ๋ผ์šฐํŒ…

๋ธ”๋ฃจํ”„๋ฆฐํŠธ๋Š” Sanic ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๋ชจ๋“ˆํ™”ํ•˜๊ณ  ๊ตฌ์กฐํ™”ํ•˜๋Š” ๊ฐ•๋ ฅํ•œ ๋ฐฉ๋ฒ•์œผ๋กœ, ๊ด€๋ จ ๊ธฐ๋Šฅ์„ ๋…ผ๋ฆฌ์ ์œผ๋กœ ๊ทธ๋ฃนํ™”ํ•˜๊ณ  ์ฝ”๋“œ ์žฌ์‚ฌ์šฉ์„ฑ์„ ๋†’์ผ ์ˆ˜ ์žˆ๋‹ค.

from sanic import Sanic, Blueprint, Request, HTTPResponse
from sanic.response import json, text, html, file, redirect
from sanic import exceptions
import logging
from typing import Dict, Any, List, Optional, Tuple

# ๋ฉ”์ธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ
app = Sanic("BlueprintApp")
logger = logging.getLogger('sanic.blueprints')

# ๊ธฐ๋ณธ API ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ์ƒ์„ฑ
api_v1 = Blueprint('api_v1', url_prefix='/api/v1')
api_v2 = Blueprint('api_v2', url_prefix='/api/v2')

# ๊ด€๋ฆฌ์ž ์˜์—ญ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ์ƒ์„ฑ
admin = Blueprint('admin', url_prefix='/admin')

# ์‚ฌ์šฉ์ž ๋ธ”๋ฃจํ”„๋ฆฐํŠธ (v1)
users_v1 = Blueprint('users_v1', url_prefix='/users')
api_v1.blueprint(users_v1)  # api_v1์— ์‚ฌ์šฉ์ž ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ์ค‘์ฒฉ

# ์‚ฌ์šฉ์ž ๋ธ”๋ฃจํ”„๋ฆฐํŠธ (v2)
users_v2 = Blueprint('users_v2', url_prefix='/users')
api_v2.blueprint(users_v2)  # api_v2์— ์‚ฌ์šฉ์ž ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ์ค‘์ฒฉ

# ์ธ์ฆ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ
auth = Blueprint('auth', url_prefix='/auth')

#################################################
# API v1 ๋ผ์šฐํŠธ ์ •์˜
#################################################

@api_v1.route('/')
async def api_index(request: Request) -> HTTPResponse:
    """API v1 ์ธ๋ฑ์Šค ํŽ˜์ด์ง€"""
    return json({
        'version': 'v1',
        'endpoints': ['/users', '/items', '/auth']
    })

@users_v1.route('/')
async def list_users_v1(request: Request) -> HTTPResponse:
    """์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (v1)"""
    users = [
        {'id': 1, 'name': 'User 1'},
        {'id': 2, 'name': 'User 2'}
    ]
    return json({'users': users})

@users_v1.route('/<user_id:int>')
async def get_user_v1(request: Request, user_id: int) -> HTTPResponse:
    """๋‹จ์ผ ์‚ฌ์šฉ์ž ์กฐํšŒ (v1)"""
    return json({'id': user_id, 'name': f'User {user_id}'})

@users_v1.post('/')
async def create_user_v1(request: Request) -> HTTPResponse:
    """์‚ฌ์šฉ์ž ์ƒ์„ฑ (v1)"""
    user_data = request.json
    # ์‚ฌ์šฉ์ž ์ƒ์„ฑ ๋กœ์ง
    return json({'status': 'created', 'user': user_data}, status=201)

# ์•„์ดํ…œ ๋ผ์šฐํŠธ - ๋ชจ๋“  HTTP ๋ฉ”์„œ๋“œ ์ฒ˜๋ฆฌ
@api_v1.route('/items/<item_id:int>', methods=['GET', 'PUT', 'DELETE'])
async def handle_item_v1(request: Request, item_id: int) -> HTTPResponse:
    """์•„์ดํ…œ ์ฒ˜๋ฆฌ (v1) - ์—ฌ๋Ÿฌ HTTP ๋ฉ”์„œ๋“œ ์ฒ˜๋ฆฌ"""
    if request.method == 'GET':
        return json({'item_id': item_id, 'name': f'Item {item_id}'})
    elif request.method == 'PUT':
        item_data = request.json
        # ์•„์ดํ…œ ์—…๋ฐ์ดํŠธ ๋กœ์ง
        return json({'status': 'updated', 'item': item_data})
    elif request.method == 'DELETE':
        # ์•„์ดํ…œ ์‚ญ์ œ ๋กœ์ง
        return json({'status': 'deleted'})

#################################################
# API v2 ๋ผ์šฐํŠธ ์ •์˜ (์ƒˆ ๊ธฐ๋Šฅ ์ถ”๊ฐ€)
#################################################

@api_v2.route('/')
async def api_index_v2(request: Request) -> HTTPResponse:
    """API v2 ์ธ๋ฑ์Šค ํŽ˜์ด์ง€"""
    return json({
        'version': 'v2',
        'endpoints': ['/users', '/items', '/auth', '/stats']
    })

@users_v2.route('/')
async def list_users_v2(request: Request) -> HTTPResponse:
    """์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (v2) - ํ–ฅ์ƒ๋œ ๊ธฐ๋Šฅ"""
    # ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ง€์›
    page = int(request.args.get('page', 1))
    per_page = int(request.args.get('per_page', 10))
    
    # ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ
    users = [
        {'id': i, 'name': f'User {i}', 'email': f'user{i}@example.com'}
        for i in range((page-1)*per_page + 1, page*per_page + 1)
    ]
    
    return json({
        'users': users,
        'pagination': {
            'page': page,
            'per_page': per_page,
            'total': 100,  # ์ƒ˜ํ”Œ ์ด ์‚ฌ์šฉ์ž ์ˆ˜
            'pages': 10
        }
    })

@users_v2.route('/<user_id:int>')
async def get_user_v2(request: Request, user_id: int) -> HTTPResponse:
    """๋‹จ์ผ ์‚ฌ์šฉ์ž ์กฐํšŒ (v2) - ํ–ฅ์ƒ๋œ ์ •๋ณด"""
    # ํ™•์žฅ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด
    return json({
        'id': user_id,
        'name': f'User {user_id}',
        'email': f'user{user_id}@example.com',
        'profile': {
            'bio': 'User bio text',
            'avatar_url': f'https://example.com/avatars/{user_id}.jpg'
        },
        'created_at': '2023-01-01T00:00:00Z'
    })

@api_v2.route('/stats')
async def get_stats(request: Request) -> HTTPResponse:
    """ํ†ต๊ณ„ API (v2์—๋งŒ ์žˆ๋Š” ์ƒˆ ๊ธฐ๋Šฅ)"""
    return json({
        'users_count': 100,
        'items_count': 500,
        'active_users': 42
    })

#################################################
# ์ธ์ฆ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ
#################################################

@auth.route('/login', methods=['POST'])
async def login(request: Request) -> HTTPResponse:
    """์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ"""
    data = request.json
    username = data.get('username')
    password = data.get('password')
    
    # ๋กœ๊ทธ์ธ ๋กœ์ง (์˜ˆ์‹œ)
    if username == 'admin' and password == 'password':
        return json({
            'token': 'sample-jwt-token',
            'user_id': 1,
            'expires_in': 3600
        })
    else:
        return json({'error': 'invalid_credentials'}, status=401)

@auth.route('/logout', methods=['POST'])
async def logout(request: Request) -> HTTPResponse:
    """์‚ฌ์šฉ์ž ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ"""
    # ๋กœ๊ทธ์•„์›ƒ ๋กœ์ง
    return json({'status': 'logged_out'})

#################################################
# ๊ด€๋ฆฌ์ž ๋ธ”๋ฃจํ”„๋ฆฐํŠธ
#################################################

@admin.route('/')
async def admin_index(request: Request) -> HTTPResponse:
    """๊ด€๋ฆฌ์ž ๋Œ€์‹œ๋ณด๋“œ ์ธ๋ฑ์Šค"""
    return html('<h1>Admin Dashboard</h1>')

@admin.route('/users')
async def admin_users(request: Request) -> HTTPResponse:
    """๊ด€๋ฆฌ์ž์šฉ ์‚ฌ์šฉ์ž ๊ด€๋ฆฌ ํŽ˜์ด์ง€"""
    return html('<h1>User Management</h1>')

#################################################
# ์ •์  ํŒŒ์ผ ๋ฐ ํŠน์ˆ˜ ๋ผ์šฐํŠธ
#################################################

# ์ •์  ํŒŒ์ผ ์ œ๊ณต (app ๋ ˆ๋ฒจ)
app.static('/static', './static')
app.static('/favicon.ico', './static/favicon.ico')

# ๋ฆฌ๋””๋ ‰์…˜ ๋ผ์šฐํŠธ
@app.route('/old-path')
async def old_path_redirect(request: Request) -> HTTPResponse:
    """์ด์ „ ๊ฒฝ๋กœ์—์„œ ์ƒˆ ๊ฒฝ๋กœ๋กœ ๋ฆฌ๋””๋ ‰์…˜"""
    return redirect('/api/v2')

# ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ๋ผ์šฐํŠธ
@app.route('/download/<filename:path>')
async def download_file(request: Request, filename: str) -> HTTPResponse:
    """ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ ์ฒ˜๋ฆฌ"""
    return await file(f'./downloads/{filename}')

# ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ์ˆ˜์ค€
@api_v1.exception(exceptions.NotFound)
async def api_v1_not_found(request: Request, exception: exceptions.NotFound) -> HTTPResponse:
    """API v1 404 ์˜ค๋ฅ˜ ํ•ธ๋“ค๋Ÿฌ"""
    return json({
        'error': 'not_found',
        'message': str(exception),
        'api_version': 'v1'
    }, status=404)

# URL ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž… ์˜ˆ์ œ
@app.route('/params/<name:str>/<value:float>/<uuid:uuid>')
async def params_example(request: Request, name: str, value: float, uuid: str) -> HTTPResponse:
    """๋‹ค์–‘ํ•œ URL ํŒŒ๋ผ๋ฏธํ„ฐ ํƒ€์ž… ์˜ˆ์ œ"""
    return json({
        'name': name,
        'value': value,
        'uuid': uuid
    })

# ๋ธ”๋ฃจํ”„๋ฆฐํŠธ ๋ฏธ๋“ค์›จ์–ด ์˜ˆ์ œ
@api_v1.middleware('request')
async def api_v1_middleware(request: Request):
    """API v1์—๋งŒ ์ ์šฉ๋˜๋Š” ๋ฏธ๋“ค์›จ์–ด"""
    request.ctx.api_version = 'v1'
    logger.info(f"API v1 ์š”์ฒญ: {request.path}")

# ๋ธ”๋ฃจํ”„๋ฆฐํŠธ๋ฅผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์— ๋“ฑ๋ก
app.blueprint(api_v1)
app.blueprint(api_v2)
app.blueprint(admin)
app.blueprint(auth)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)

โœ… ํŠน์ง•:

  • ๊ธฐ๋Šฅ๋ณ„ ๋ชจ๋“ˆํ™”๋ฅผ ํ†ตํ•œ ์ฝ”๋“œ ๊ตฌ์กฐํ™”
  • URL ์ ‘๋‘์‚ฌ๋ฅผ ํ†ตํ•œ API ๊ฒฝ๋กœ ์กฐ์งํ™”
  • ์ค‘์ฒฉ ๋ธ”๋ฃจํ”„๋ฆฐํŠธ๋กœ ๋ณต์žกํ•œ ๋ผ์šฐํŒ… ๊ณ„์ธต ๊ตฌ์„ฑ
  • HTTP ๋ฉ”์„œ๋“œ๋ณ„ ๋ผ์šฐํŠธ ์ฒ˜๋ฆฌ (GET, POST, PUT, DELETE ๋“ฑ)
  • API ๋ฒ„์ „ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ ํšจ๊ณผ์ ์ธ ๊ตฌ์กฐ
  • ๋ธ”๋ฃจํ”„๋ฆฐํŠธ๋ณ„ ๋ฏธ๋“ค์›จ์–ด ์ •์˜ ๊ฐ€๋Šฅ
  • ๋ธ”๋ฃจํ”„๋ฆฐํŠธ๋ณ„ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๊ธฐ๋Šฅ
  • ๋‹ค์–‘ํ•œ ์‘๋‹ต ํƒ€์ž… ์ง€์› (JSON, HTML, ํŒŒ์ผ, ๋ฆฌ๋””๋ ‰์…˜)
  • ์ •์  ํŒŒ์ผ ์„œ๋น™ ๊ธฐ๋Šฅ
  • URL ํŒŒ๋ผ๋ฏธํ„ฐ์˜ ํƒ€์ž… ๊ฒ€์ฆ ๋ฐ ๋ณ€ํ™˜
  • ์š”์ฒญ ์ปจํ…์ŠคํŠธ๋ฅผ ํ†ตํ•œ ๋ฐ์ดํ„ฐ ์ €์žฅ ๋ฐ ๊ณต์œ 
  • RESTful API ์„ค๊ณ„ ํŒจํ„ด ์ง€์›


4๏ธโƒฃ ์ธ์ฆ๊ณผ ๋ณด์•ˆ

Sanic์—์„œ๋Š” ๋ฏธ๋“ค์›จ์–ด, ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ, ๊ทธ๋ฆฌ๊ณ  JWT์™€ ๊ฐ™์€ ๋„๊ตฌ๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๊ฒฌ๊ณ ํ•œ ์ธ์ฆ ์‹œ์Šคํ…œ๊ณผ ๋ณด์•ˆ ๋ฉ”์ปค๋‹ˆ์ฆ˜์„ ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ๋‹ค.

from sanic import Sanic, Blueprint, Request, HTTPResponse
from sanic.response import json
from sanic.exceptions import Unauthorized, Forbidden
from functools import wraps
import jwt
import time
import os
import logging
import uuid
import hashlib
import secrets
from typing import Dict, Any, Callable, Optional

# ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ •
app = Sanic("SecureApp")
app.config.SECRET_KEY = os.environ.get('SECRET_KEY', 'your-secret-key-here')  # ์‹ค์ œ๋ก  ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์‚ฌ์šฉ

# ๋กœ๊ฑฐ ์„ค์ •
logger = logging.getLogger('sanic.security')

# ์‚ฌ์šฉ์ž ์ž„์‹œ ๋ฐ์ดํ„ฐ (์‹ค์ œ๋กœ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ๊ด€๋ฆฌ)
USERS = {
    1: {"id": 1, "username": "admin", "password_hash": hashlib.sha256("admin_password".encode()).hexdigest(), "role": "admin"},
    2: {"id": 2, "username": "user", "password_hash": hashlib.sha256("user_password".encode()).hexdigest(), "role": "user"}
}

# ์ฐจ๋‹จ๋œ ํ† ํฐ ์ถ”์  (Redis ๋“ฑ์œผ๋กœ ๋Œ€์ฒด ๊ฐ€๋Šฅ)
BLACKLISTED_TOKENS = set()

####################################################
# ํ† ํฐ ์ƒ์„ฑ ๋ฐ ๊ด€๋ฆฌ ํ•จ์ˆ˜
####################################################

def generate_token(user_id: int, role: str = "user", expiry: int = 3600) -> str:
    """
    JWT ํ† ํฐ ์ƒ์„ฑ
    
    Args:
        user_id: ์‚ฌ์šฉ์ž ID
        role: ์‚ฌ์šฉ์ž ์—ญํ• 
        expiry: ๋งŒ๋ฃŒ ์‹œ๊ฐ„(์ดˆ)
        
    Returns:
        str: ์ƒ์„ฑ๋œ JWT ํ† ํฐ
    """
    payload = {
        'sub': user_id,
        'role': role,
        'iat': int(time.time()),
        'exp': int(time.time()) + expiry,
        'jti': str(uuid.uuid4())
    }
    
    token = jwt.encode(
        payload,
        app.config.SECRET_KEY,
        algorithm='HS256'
    )
    
    logger.info(f"์‚ฌ์šฉ์ž ID {user_id}์˜ ํ† ํฐ ์ƒ์„ฑ๋จ")
    return token

def verify_token(token: str) -> Dict[str, Any]:
    """
    JWT ํ† ํฐ ๊ฒ€์ฆ
    
    Args:
        token: JWT ํ† ํฐ
        
    Returns:
        Dict: ํ† ํฐ ํŽ˜์ด๋กœ๋“œ
        
    Raises:
        jwt.InvalidTokenError: ์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ
        jwt.ExpiredSignatureError: ๋งŒ๋ฃŒ๋œ ํ† ํฐ
    """
    # ์ฐจ๋‹จ ๋ชฉ๋ก ํ™•์ธ
    if token in BLACKLISTED_TOKENS:
        raise jwt.InvalidTokenError("Blacklisted token")
    
    # ํ† ํฐ ๊ฒ€์ฆ
    payload = jwt.decode(
        token,
        app.config.SECRET_KEY,
        algorithms=['HS256']
    )
    
    return payload

####################################################
# ์ธ์ฆ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ
####################################################

def protected(roles: Optional[list] = None) -> Callable:
    """
    ๋ณดํ˜ธ๋œ ๊ฒฝ๋กœ๋ฅผ ์œ„ํ•œ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ
    
    Args:
        roles: ํ—ˆ์šฉ๋œ ์—ญํ•  ๋ชฉ๋ก (None์ด๋ฉด ๋ชจ๋“  ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ํ—ˆ์šฉ)
        
    Returns:
        Callable: ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ํ•จ์ˆ˜
    """
    def decorator(f):
        @wraps(f)
        async def decorated_function(request: Request, *args, **kwargs):
            # ์ธ์ฆ ํ—ค๋” ํ™•์ธ
            auth_header = request.headers.get('Authorization')
            if not auth_header:
                logger.warning(f"์ธ์ฆ ํ—ค๋” ๋ˆ„๋ฝ: {request.path}")
                raise Unauthorized("์ธ์ฆ ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")
            
            # ํ—ค๋” ํ˜•์‹ ํ™•์ธ
            parts = auth_header.split()
            if parts[0].lower() != 'bearer' or len(parts) != 2:
                logger.warning(f"์ž˜๋ชป๋œ ์ธ์ฆ ํ—ค๋” ํ˜•์‹: {auth_header}")
                raise Unauthorized("์ž˜๋ชป๋œ ์ธ์ฆ ํ—ค๋” ํ˜•์‹")
            
            token = parts[1]
            
            try:
                # ํ† ํฐ ๊ฒ€์ฆ
                payload = verify_token(token)
                
                # ์‚ฌ์šฉ์ž ์ •๋ณด ์š”์ฒญ ์ปจํ…์ŠคํŠธ์— ์ €์žฅ
                request.ctx.user_id = payload['sub']
                request.ctx.role = payload['role']
                
                # ์—ญํ•  ๊ธฐ๋ฐ˜ ์•ก์„ธ์Šค ์ œ์–ด ํ™•์ธ
                if roles and payload['role'] not in roles:
                    logger.warning(f"๊ถŒํ•œ ๋ถ€์กฑ - ์‚ฌ์šฉ์ž {request.ctx.user_id}, ์—ญํ• : {request.ctx.role}, ํ•„์š”: {roles}")
                    raise Forbidden("์ด ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค")
                
                # ์›๋ž˜ ํ•ธ๋“ค๋Ÿฌ ํ˜ธ์ถœ
                return await f(request, *args, **kwargs)
                
            except jwt.ExpiredSignatureError:
                logger.warning(f"๋งŒ๋ฃŒ๋œ ํ† ํฐ: {token[:10]}...")
                raise Unauthorized("ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค")
                
            except jwt.InvalidTokenError as e:
                logger.warning(f"์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ: {token[:10]}..., ์˜ค๋ฅ˜: {str(e)}")
                raise Unauthorized("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค")
                
            except Exception as e:
                logger.error(f"์ธ์ฆ ์ค‘ ์˜ค๋ฅ˜: {str(e)}")
                raise Unauthorized("์ธ์ฆ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค")
        
        return decorated_function
    return decorator

####################################################
# ๋ณด์•ˆ ํ—ค๋” ๋ฏธ๋“ค์›จ์–ด
####################################################

@app.middleware('response')
async def add_security_headers(request: Request, response):
    """๋ณด์•ˆ ๊ด€๋ จ ํ—ค๋” ์ถ”๊ฐ€"""
    # XSS ๋ฐฉ์ง€
    response.headers['X-XSS-Protection'] = '1; mode=block'
    # ํด๋ฆญ์žฌํ‚น ๋ฐฉ์ง€
    response.headers['X-Frame-Options'] = 'DENY'
    # MIME ์Šค๋‹ˆํ•‘ ๋ฐฉ์ง€
    response.headers['X-Content-Type-Options'] = 'nosniff'
    # ์ฝ˜ํ…์ธ  ๋ณด์•ˆ ์ •์ฑ… ์„ค์ •
    response.headers['Content-Security-Policy'] = "default-src 'self'"
    # HSTS ์„ค์ • (HTTPS ์‚ฌ์šฉ ์‹œ)
    response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
    # ์บ์‹œ ์ œ์–ด (์ธ์ฆ์ด ํ•„์š”ํ•œ ํŽ˜์ด์ง€)
    if hasattr(request.ctx, 'user_id'):
        response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
        response.headers['Pragma'] = 'no-cache'

####################################################
# CSRF ๋ณดํ˜ธ ๋ฏธ๋“ค์›จ์–ด
####################################################

CSRF_TOKENS = {}  # ์‹ค์ œ ๊ตฌํ˜„์—์„œ๋Š” Redis ๋“ฑ ์‚ฌ์šฉ

def generate_csrf_token() -> str:
    """CSRF ํ† ํฐ ์ƒ์„ฑ"""
    return secrets.token_hex(16)

@app.middleware('request')
async def csrf_protection(request: Request):
    """CSRF ๋ณดํ˜ธ ๋ฏธ๋“ค์›จ์–ด"""
    # CSRF ๋ณดํ˜ธ๊ฐ€ ํ•„์š” ์—†๋Š” ๋ฉ”์„œ๋“œ ๋˜๋Š” ๊ฒฝ๋กœ ์ œ์™ธ
    safe_methods = ['GET', 'HEAD', 'OPTIONS']
    bypass_paths = ['/api/token', '/login']
    
    if request.method in safe_methods or request.path in bypass_paths:
        return
    
    # CSRF ํ† ํฐ ํ™•์ธ
    request_csrf = request.headers.get('X-CSRF-Token')
    session_id = request.cookies.get('session_id')
    
    if not session_id or not request_csrf or CSRF_TOKENS.get(session_id) != request_csrf:
        logger.warning(f"CSRF ํ† ํฐ ๋ถˆ์ผ์น˜: {request.path}")
        raise Forbidden("์œ ํšจํ•˜์ง€ ์•Š์€ CSRF ํ† ํฐ")

####################################################
# ์‚ฌ์šฉ์ž ์ธ์ฆ ๋ผ์šฐํŠธ
####################################################

@app.route('/api/token', methods=['POST'])
async def get_token(request: Request) -> HTTPResponse:
    """
    ์‚ฌ์šฉ์ž ์ธ์ฆ ๋ฐ ํ† ํฐ ๋ฐœ๊ธ‰
    
    Request:
        JSON: {username, password}
        
    Response:
        JSON: {token, expires_in}
    """
    data = request.json
    username = data.get('username')
    password = data.get('password')
    
    if not username or not password:
        return json({'error': 'missing_fields', 'message': '์‚ฌ์šฉ์ž๋ช…๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค'}, status=400)
    
    # ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹ฑ
    hashed_password = hashlib.sha256(password.encode()).hexdigest()
    
    # ์‚ฌ์šฉ์ž ํ™•์ธ (์‹ค์ œ๋กœ๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์กฐํšŒ)
    user = None
    for u in USERS.values():
        if u['username'] == username and u['password_hash'] == hashed_password:
            user = u
            break
    
    if user:
        # ํ† ํฐ ์ƒ์„ฑ
        token = generate_token(user['id'], user['role'])
        
        # ์„ธ์…˜ ์„ค์ • ๋ฐ CSRF ํ† ํฐ ์ƒ์„ฑ
        session_id = str(uuid.uuid4())
        csrf_token = generate_csrf_token()
        CSRF_TOKENS[session_id] = csrf_token
        
        response = json({
            'token': token,
            'csrf_token': csrf_token,
            'expires_in': 3600,
            'user_id': user['id'],
            'role': user['role']
        })
        
        # ์„ธ์…˜ ์ฟ ํ‚ค ์„ค์ •
        response.cookies['session_id'] = session_id
        response.cookies['session_id']['httponly'] = True
        response.cookies['session_id']['secure'] = True  # ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ HTTPS
        response.cookies['session_id']['samesite'] = 'Lax'
        
        return response
    else:
        logger.warning(f"๋กœ๊ทธ์ธ ์‹คํŒจ: {username}")
        return json({'error': 'invalid_credentials', 'message': '์ž˜๋ชป๋œ ์‚ฌ์šฉ์ž๋ช… ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ'}, status=401)

@app.route('/api/logout', methods=['POST'])
@protected()
async def logout(request: Request) -> HTTPResponse:
    """
    ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ
    - ํ˜„์žฌ ํ† ํฐ์„ ๋ฌดํšจํ™”
    """
    # ํ˜„์žฌ ํ† ํฐ ์ถ”์ถœ
    token = request.headers.get('Authorization').split()[1]
    
    # ํ† ํฐ ์ฐจ๋‹จ ๋ชฉ๋ก์— ์ถ”๊ฐ€
    BLACKLISTED_TOKENS.add(token)
    
    # ์„ธ์…˜ ID๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ CSRF ํ† ํฐ ์ œ๊ฑฐ
    session_id = request.cookies.get('session_id')
    if session_id and session_id in CSRF_TOKENS:
        del CSRF_TOKENS[session_id]
    
    # ์ฟ ํ‚ค ํด๋ฆฌ์–ด ์‘๋‹ต
    response = json({'status': 'logged_out'})
    response.cookies['session_id'] = ''
    response.cookies['session_id']['expires'] = 0
    
    return response

####################################################
# ๋ณดํ˜ธ๋œ ๋ผ์šฐํŠธ ์˜ˆ์ œ
####################################################

@app.route('/api/profile')
@protected()
async def get_profile(request: Request) -> HTTPResponse:
    """
    ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ •๋ณด ์กฐํšŒ (๋ชจ๋“  ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž)
    """
    user_id = request.ctx.user_id
    user = USERS.get(user_id)
    
    if not user:
        return json({'error': 'user_not_found'}, status=404)
    
    # ๋ฏผ๊ฐํ•œ ์ •๋ณด ์ œ์™ธ
    return json({
        'id': user['id'],
        'username': user['username'],
        'role': user['role']
    })

@app.route('/api/admin/users')
@protected(['admin'])
async def list_users(request: Request) -> HTTPResponse:
    """
    ๋ชจ๋“  ์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ (๊ด€๋ฆฌ์ž๋งŒ)
    """
    # ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๋ฐ˜ํ™˜ (๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹œ ์ œ์™ธ)
    users_list = [
        {'id': u['id'], 'username': u['username'], 'role': u['role']}
        for u in USERS.values()
    ]
    return json({'users': users_list})

# ๊ธฐ๋ณธ ์˜ค๋ฅ˜ ํ•ธ๋“ค๋Ÿฌ
@app.exception(Unauthorized)
async def unauthorized(request: Request, exception: Unauthorized) -> HTTPResponse:
    return json({
        'error': 'unauthorized',
        'message': str(exception)
    }, status=401)

@app.exception(Forbidden)
async def forbidden(request: Request, exception: Forbidden) -> HTTPResponse:
    return json({
        'error': 'forbidden',
        'message': str(exception)
    }, status=403)

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)

โœ… ํŠน์ง•:

  • JWT ๊ธฐ๋ฐ˜ ์ธ์ฆ ์‹œ์Šคํ…œ ๊ตฌํ˜„
  • ์—ญํ•  ๊ธฐ๋ฐ˜ ์ ‘๊ทผ ์ œ์–ด(RBAC) ๊ตฌํ˜„
  • ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ํŒจํ„ด์„ ํ†ตํ•œ ๊ฒฝ๋กœ ๋ณดํ˜ธ
  • CSRF ํ† ํฐ ๋ฐฉ์–ด ๋ฉ”์ปค๋‹ˆ์ฆ˜
  • ๋ณด์•ˆ ๊ฐ•ํ™” HTTP ํ—ค๋” ์ถ”๊ฐ€
  • ํ† ํฐ ์ฐจ๋‹จ ๋ชฉ๋ก ์ง€์›
  • ์„ธ์…˜ ๊ด€๋ฆฌ ๋ฐ ์•ˆ์ „ํ•œ ์ฟ ํ‚ค ์‚ฌ์šฉ
  • ๋น„๋ฐ€๋ฒˆํ˜ธ ํ•ด์‹œ ์ฒ˜๋ฆฌ
  • ์‚ฌ์šฉ์ž ์ธ์ฆ ๋ฐ ๋กœ๊ทธ์•„์›ƒ ์›Œํฌํ”Œ๋กœ์šฐ
  • ์ƒ์„ธํ•œ ๋ณด์•ˆ ๋กœ๊น…
  • ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ ๋ฐ ํ‘œ์ค€ํ™”๋œ ์‘๋‹ต
  • ๋น„๋™๊ธฐ ์ธ์ฆ ์ฒ˜๋ฆฌ
  • ํ™˜๊ฒฝ ์„ค์ • ๋ถ„๋ฆฌ (๋น„๋ฐ€ํ‚ค ๊ด€๋ฆฌ)


์ฃผ์š” ํŒ

โœ… ๋ชจ๋ฒ” ์‚ฌ๋ก€:

  • ๋น„๋™๊ธฐ ์ฝ”๋“œ ์ตœ์ ํ™”
  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๊ตฌํ˜„
  • ๋ฏธ๋“ค์›จ์–ด ํ™œ์šฉ
  • ์บ์‹ฑ ์ „๋žต
  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ตœ์ ํ™”
  • ๋ณด์•ˆ ์„ค์ •
  • ๋กœ๊น… ๊ตฌํ˜„
  • ํ…Œ์ŠคํŠธ ์ž‘์„ฑ


โš ๏ธ **GitHub.com Fallback** โš ๏ธ