feat: OAuth 2.0 백엔드 시스템 구현 완료

Phase 1 & 2 완료:
- 프로젝트 기본 구조 설정
- Docker Compose 환경 구성 (MongoDB, Redis, Backend, Frontend)
- FastAPI 기반 OAuth 2.0 백엔드 구현

주요 기능:
- JWT 기반 인증 시스템
- 3단계 권한 체계 (System Admin/Group Admin/User)
- 사용자 관리 CRUD API
- 애플리케이션 관리 CRUD API
- OAuth 2.0 Authorization Code Flow
- Refresh Token 관리
- 인증 히스토리 추적

API 엔드포인트:
- /auth/* - 인증 관련 (register, login, logout, refresh)
- /users/* - 사용자 관리
- /applications/* - 애플리케이션 관리
- /oauth/* - OAuth 2.0 플로우

보안 기능:
- bcrypt 비밀번호 해싱
- JWT 토큰 인증
- CORS 설정
- Rate limiting 준비

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-09-05 14:56:02 +09:00
parent abdcc31245
commit 6c21809a24
25 changed files with 2012 additions and 45 deletions

View File

@ -0,0 +1,5 @@
"""API routers"""
from .auth import router as auth_router
from .users import router as users_router
from .applications import router as applications_router

View File

@ -0,0 +1,256 @@
"""Applications management router"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List
from bson import ObjectId
from datetime import datetime
from app.models.application import Application, ApplicationCreate, ApplicationUpdate, ApplicationPublic
from app.models.user import User, UserRole
from app.routers.auth import get_current_user
from app.utils.database import get_database
from app.utils.security import generate_client_id, generate_client_secret
router = APIRouter(prefix="/applications", tags=["Applications"])
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require admin role"""
if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
@router.get("/", response_model=List[Application])
async def get_applications(
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_user)
):
"""Get applications (own applications for users, all for admins)"""
db = get_database()
query = {}
if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
query = {"created_by": str(current_user.id)}
apps = await db.applications.find(query).skip(skip).limit(limit).to_list(limit)
return [Application(**app) for app in apps]
@router.post("/", response_model=Application)
async def create_application(
app_data: ApplicationCreate,
current_user: User = Depends(get_current_user)
):
"""Create a new application"""
db = get_database()
# Generate client credentials
client_id = generate_client_id()
client_secret = generate_client_secret()
# Check if client_id already exists (unlikely but possible)
existing_app = await db.applications.find_one({"client_id": client_id})
if existing_app:
# Regenerate if collision
client_id = generate_client_id()
# Prepare application document
app_doc = {
**app_data.model_dump(),
"client_id": client_id,
"client_secret": client_secret,
"created_by": str(current_user.id),
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
# Insert application
result = await db.applications.insert_one(app_doc)
app_doc["_id"] = result.inserted_id
return Application(**app_doc)
@router.get("/public/{client_id}", response_model=ApplicationPublic)
async def get_application_public(client_id: str):
"""Get public application information (for OAuth flow)"""
db = get_database()
app = await db.applications.find_one({"client_id": client_id, "is_active": True})
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
return ApplicationPublic(**app)
@router.get("/{app_id}", response_model=Application)
async def get_application(app_id: str, current_user: User = Depends(get_current_user)):
"""Get application by ID"""
db = get_database()
try:
app = await db.applications.find_one({"_id": ObjectId(app_id)})
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
# Check permissions
if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return Application(**app)
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid application ID"
)
@router.put("/{app_id}", response_model=Application)
async def update_application(
app_id: str,
app_update: ApplicationUpdate,
current_user: User = Depends(get_current_user)
):
"""Update application"""
db = get_database()
try:
# Get existing application
app = await db.applications.find_one({"_id": ObjectId(app_id)})
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
# Check permissions
if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Prepare update data
update_data = app_update.model_dump(exclude_unset=True)
update_data["updated_at"] = datetime.utcnow()
# Update application
result = await db.applications.update_one(
{"_id": ObjectId(app_id)},
{"$set": update_data}
)
# Return updated application
app = await db.applications.find_one({"_id": ObjectId(app_id)})
return Application(**app)
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/{app_id}/regenerate-secret", response_model=Application)
async def regenerate_client_secret(
app_id: str,
current_user: User = Depends(get_current_user)
):
"""Regenerate client secret"""
db = get_database()
try:
# Get existing application
app = await db.applications.find_one({"_id": ObjectId(app_id)})
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
# Check permissions
if str(app["created_by"]) != str(current_user.id) and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Generate new secret
new_secret = generate_client_secret()
# Update application
await db.applications.update_one(
{"_id": ObjectId(app_id)},
{"$set": {
"client_secret": new_secret,
"updated_at": datetime.utcnow()
}}
)
# Return updated application
app = await db.applications.find_one({"_id": ObjectId(app_id)})
return Application(**app)
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{app_id}")
async def delete_application(
app_id: str,
current_user: User = Depends(get_current_user)
):
"""Delete application"""
db = get_database()
try:
# Get existing application
app = await db.applications.find_one({"_id": ObjectId(app_id)})
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found"
)
# Check permissions
if str(app["created_by"]) != str(current_user.id) and current_user.role != UserRole.SYSTEM_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Delete application
result = await db.applications.delete_one({"_id": ObjectId(app_id)})
return {"message": "Application deleted successfully"}
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid application ID"
)

View File

@ -0,0 +1,164 @@
"""Authentication router"""
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from typing import Optional
from app.models.user import User, UserLogin, Token, TokenRefresh, UserCreate
from app.models.auth_history import AuthAction
from app.services.auth_service import AuthService
from app.services.token_service import TokenService
from app.utils.security import decode_token
router = APIRouter(prefix="/auth", tags=["Authentication"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
"""Get current authenticated user"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
raise credentials_exception
user = await AuthService.get_user_by_id(payload.get("sub"))
if user is None:
raise credentials_exception
return user
@router.post("/register", response_model=User)
async def register(user_data: UserCreate, request: Request):
"""Register a new user"""
try:
user = await AuthService.create_user(user_data)
# Log registration
await AuthService.log_auth_action(
user_id=str(user.id),
action=AuthAction.REGISTER,
ip_address=request.client.host,
user_agent=request.headers.get("User-Agent")
)
return user
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/login", response_model=Token)
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
"""Login with username/email and password"""
user = await AuthService.authenticate_user(form_data.username, form_data.password)
if not user:
# Log failed login attempt
await AuthService.log_auth_action(
user_id="unknown",
action=AuthAction.FAILED_LOGIN,
ip_address=request.client.host,
user_agent=request.headers.get("User-Agent"),
result="failed",
details={"username": form_data.username}
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create tokens
tokens = await TokenService.create_tokens(
str(user.id),
{
"username": user.username,
"role": user.role,
"email": user.email
}
)
# Log successful login
await AuthService.log_auth_action(
user_id=str(user.id),
action=AuthAction.LOGIN,
ip_address=request.client.host,
user_agent=request.headers.get("User-Agent")
)
return tokens
@router.post("/refresh", response_model=Token)
async def refresh_token(request: Request, token_data: TokenRefresh):
"""Refresh access token using refresh token"""
user_id = await TokenService.verify_refresh_token(token_data.refresh_token)
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# Get user
user = await AuthService.get_user_by_id(user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
# Revoke old refresh token
await TokenService.revoke_refresh_token(token_data.refresh_token)
# Create new tokens
tokens = await TokenService.create_tokens(
str(user.id),
{
"username": user.username,
"role": user.role,
"email": user.email
}
)
# Log token refresh
await AuthService.log_auth_action(
user_id=str(user.id),
action=AuthAction.TOKEN_REFRESH,
ip_address=request.client.host,
user_agent=request.headers.get("User-Agent")
)
return tokens
@router.post("/logout")
async def logout(request: Request, current_user: User = Depends(get_current_user)):
"""Logout current user"""
# Revoke all user tokens
await TokenService.revoke_all_user_tokens(str(current_user.id))
# Log logout
await AuthService.log_auth_action(
user_id=str(current_user.id),
action=AuthAction.LOGOUT,
ip_address=request.client.host,
user_agent=request.headers.get("User-Agent")
)
return {"message": "Successfully logged out"}
@router.get("/me", response_model=User)
async def get_me(current_user: User = Depends(get_current_user)):
"""Get current user information"""
return current_user

View File

@ -0,0 +1,156 @@
"""Users management router"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List, Optional
from bson import ObjectId
from app.models.user import User, UserUpdate, UserRole
from app.routers.auth import get_current_user
from app.utils.database import get_database
from app.utils.security import hash_password
from datetime import datetime
router = APIRouter(prefix="/users", tags=["Users"])
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Require admin role"""
if current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
@router.get("/", response_model=List[User])
async def get_users(
skip: int = Query(0, ge=0),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(require_admin)
):
"""Get all users (admin only)"""
db = get_database()
users = await db.users.find().skip(skip).limit(limit).to_list(limit)
return [User(**user) for user in users]
@router.get("/{user_id}", response_model=User)
async def get_user(user_id: str, current_user: User = Depends(get_current_user)):
"""Get user by ID"""
# Users can only view their own profile unless they're admin
if str(current_user.id) != user_id and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
db = get_database()
try:
user = await db.users.find_one({"_id": ObjectId(user_id)})
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return User(**user)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
@router.put("/{user_id}", response_model=User)
async def update_user(
user_id: str,
user_update: UserUpdate,
current_user: User = Depends(get_current_user)
):
"""Update user"""
# Users can only update their own profile unless they're admin
if str(current_user.id) != user_id and current_user.role not in [UserRole.SYSTEM_ADMIN, UserRole.GROUP_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Only system admin can change roles
if user_update.role and current_user.role != UserRole.SYSTEM_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only system admin can change user roles"
)
db = get_database()
try:
# Prepare update data
update_data = user_update.model_dump(exclude_unset=True)
# Hash password if provided
if "password" in update_data:
update_data["hashed_password"] = hash_password(update_data.pop("password"))
# Update timestamp
update_data["updated_at"] = datetime.utcnow()
# Update user
result = await db.users.update_one(
{"_id": ObjectId(user_id)},
{"$set": update_data}
)
if result.modified_count == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Return updated user
user = await db.users.find_one({"_id": ObjectId(user_id)})
return User(**user)
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
current_user: User = Depends(require_admin)
):
"""Delete user (admin only)"""
# Prevent self-deletion
if str(current_user.id) == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
db = get_database()
try:
result = await db.users.delete_one({"_id": ObjectId(user_id)})
if result.deleted_count == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return {"message": "User deleted successfully"}
except Exception as e:
if isinstance(e, HTTPException):
raise
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)