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:
5
oauth/backend/app/routers/__init__.py
Normal file
5
oauth/backend/app/routers/__init__.py
Normal 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
|
||||
256
oauth/backend/app/routers/applications.py
Normal file
256
oauth/backend/app/routers/applications.py
Normal 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"
|
||||
)
|
||||
164
oauth/backend/app/routers/auth.py
Normal file
164
oauth/backend/app/routers/auth.py
Normal 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
|
||||
156
oauth/backend/app/routers/users.py
Normal file
156
oauth/backend/app/routers/users.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user