Files
OCR/backend/app/routers/auth.py
egg fd98018ddd refactor: complete V1 to V2 migration and remove legacy architecture
Remove all V1 architecture components and promote V2 to primary:
- Delete all paddle_ocr_* table models (export, ocr, translation, user)
- Delete legacy routers (auth, export, ocr, translation)
- Delete legacy schemas and services
- Promote user_v2.py to user.py as primary user model
- Update all imports and dependencies to use V2 models only
- Update main.py version to 2.0.0

Database changes:
- Fix SQLAlchemy reserved word: rename audit_log.metadata to extra_data
- Add migration to drop all paddle_ocr_* tables
- Update alembic env to only import V2 models

Frontend fixes:
- Fix Select component exports in TaskHistoryPage.tsx
- Update to use simplified Select API with options prop
- Fix AxiosInstance TypeScript import syntax

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:27:39 +08:00

348 lines
11 KiB
Python

"""
Tool_OCR - External Authentication Router (V2)
Handles authentication via external Microsoft Azure AD API
"""
from datetime import datetime, timedelta
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.deps import get_db, get_current_user
from app.core.security import create_access_token
from app.models.user import User
from app.models.session import Session as UserSession
from app.schemas.auth import LoginRequest, Token, UserResponse
from app.services.external_auth_service import external_auth_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/auth", tags=["Authentication V2"])
def get_client_ip(request: Request) -> str:
"""Extract client IP address from request"""
# Check X-Forwarded-For header (for proxies)
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
# Check X-Real-IP header
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
# Fallback to direct client
return request.client.host if request.client else "unknown"
def get_user_agent(request: Request) -> str:
"""Extract user agent from request"""
return request.headers.get("User-Agent", "unknown")[:500]
@router.post("/login", response_model=Token, summary="External API login")
async def login(
login_data: LoginRequest,
request: Request,
db: Session = Depends(get_db)
):
"""
User login via external Microsoft Azure AD API
Returns JWT access token and stores session information
- **username**: User's email address
- **password**: User's password
"""
# Call external authentication API
success, auth_response, error_msg = await external_auth_service.authenticate_user(
username=login_data.username,
password=login_data.password
)
if not success or not auth_response:
logger.warning(
f"External auth failed for user {login_data.username}: {error_msg}"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=error_msg or "Authentication failed",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract user info from external API response
user_info = auth_response.user_info
email = user_info.email
display_name = user_info.name
# Find or create user in database
user = db.query(User).filter(User.email == email).first()
if not user:
# Create new user
user = User(
email=email,
display_name=display_name,
is_active=True,
last_login=datetime.utcnow()
)
db.add(user)
db.commit()
db.refresh(user)
logger.info(f"Created new user: {email} (ID: {user.id})")
else:
# Update existing user
user.display_name = display_name
user.last_login = datetime.utcnow()
# Check if user is active
if not user.is_active:
logger.warning(f"Inactive user login attempt: {email}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive"
)
db.commit()
db.refresh(user)
logger.info(f"Updated existing user: {email} (ID: {user.id})")
# Parse token expiration
try:
expires_at = datetime.fromisoformat(auth_response.expires_at.replace('Z', '+00:00'))
issued_at = datetime.fromisoformat(auth_response.issued_at.replace('Z', '+00:00'))
except Exception as e:
logger.error(f"Failed to parse token timestamps: {e}")
expires_at = datetime.utcnow() + timedelta(seconds=auth_response.expires_in)
issued_at = datetime.utcnow()
# Create session in database
# TODO: Implement token encryption before storing
session = UserSession(
user_id=user.id,
access_token=auth_response.access_token, # Should be encrypted
id_token=auth_response.id_token, # Should be encrypted
token_type=auth_response.token_type,
expires_at=expires_at,
issued_at=issued_at,
ip_address=get_client_ip(request),
user_agent=get_user_agent(request)
)
db.add(session)
db.commit()
db.refresh(session)
logger.info(
f"Created session {session.id} for user {user.email} "
f"(expires: {expires_at})"
)
# Create internal JWT token for API access
# This token contains user ID and session ID
internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
internal_access_token = create_access_token(
data={
"sub": str(user.id),
"email": user.email,
"session_id": session.id
},
expires_delta=internal_token_expires
)
return {
"access_token": internal_access_token,
"token_type": "bearer",
"expires_in": int(internal_token_expires.total_seconds()),
"user": {
"id": user.id,
"email": user.email,
"display_name": user.display_name
}
}
@router.post("/logout", summary="User logout")
async def logout(
session_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
User logout - invalidates session
- **session_id**: Session ID to logout (optional, logs out all if not provided)
"""
# TODO: Implement proper current_user dependency from JWT token
# For now, this is a placeholder
if session_id:
# Logout specific session
session = db.query(UserSession).filter(
UserSession.id == session_id,
UserSession.user_id == current_user.id
).first()
if session:
db.delete(session)
db.commit()
logger.info(f"Logged out session {session_id} for user {current_user.email}")
return {"message": "Logged out successfully"}
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
else:
# Logout all sessions
sessions = db.query(UserSession).filter(
UserSession.user_id == current_user.id
).all()
count = len(sessions)
for session in sessions:
db.delete(session)
db.commit()
logger.info(f"Logged out all {count} sessions for user {current_user.email}")
return {"message": f"Logged out {count} sessions"}
@router.get("/me", response_model=UserResponse, summary="Get current user")
async def get_me(
current_user: User = Depends(get_current_user)
):
"""
Get current authenticated user information
"""
# TODO: Implement proper current_user dependency from JWT token
return {
"id": current_user.id,
"email": current_user.email,
"display_name": current_user.display_name,
"created_at": current_user.created_at,
"last_login": current_user.last_login,
"is_active": current_user.is_active
}
@router.get("/sessions", summary="List user sessions")
async def list_sessions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
List all active sessions for current user
"""
sessions = db.query(UserSession).filter(
UserSession.user_id == current_user.id
).order_by(UserSession.created_at.desc()).all()
return {
"sessions": [
{
"id": s.id,
"token_type": s.token_type,
"expires_at": s.expires_at,
"issued_at": s.issued_at,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
"created_at": s.created_at,
"last_accessed_at": s.last_accessed_at,
"is_expired": s.is_expired,
"time_until_expiry": s.time_until_expiry
}
for s in sessions
]
}
@router.post("/refresh", response_model=Token, summary="Refresh access token")
async def refresh_token(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Refresh access token before expiration
Re-authenticates with external API using stored session.
Note: Since external API doesn't provide refresh tokens,
we re-issue internal JWT tokens with extended expiry.
"""
try:
# Find user's most recent session
session = db.query(UserSession).filter(
UserSession.user_id == current_user.id
).order_by(UserSession.created_at.desc()).first()
if not session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No active session found"
)
# Check if token is expiring soon (within TOKEN_REFRESH_BUFFER)
if not external_auth_service.is_token_expiring_soon(session.expires_at):
# Token still valid for a while, just issue new internal JWT
internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
internal_access_token = create_access_token(
data={
"sub": str(current_user.id),
"email": current_user.email,
"session_id": session.id
},
expires_delta=internal_token_expires
)
logger.info(f"Refreshed internal token for user {current_user.email}")
return {
"access_token": internal_access_token,
"token_type": "bearer",
"expires_in": int(internal_token_expires.total_seconds()),
"user": {
"id": current_user.id,
"email": current_user.email,
"display_name": current_user.display_name
}
}
# External token expiring soon - would need re-authentication
# For now, we extend internal token and log a warning
logger.warning(
f"External token expiring soon for user {current_user.email}. "
"User should re-authenticate."
)
internal_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
internal_access_token = create_access_token(
data={
"sub": str(current_user.id),
"email": current_user.email,
"session_id": session.id
},
expires_delta=internal_token_expires
)
return {
"access_token": internal_access_token,
"token_type": "bearer",
"expires_in": int(internal_token_expires.total_seconds()),
"user": {
"id": current_user.id,
"email": current_user.email,
"display_name": current_user.display_name
}
}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Token refresh failed for user {current_user.id}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Token refresh failed: {str(e)}"
)