feat: implement user authentication module
- Backend (FastAPI): - External API authentication (pj-auth-api.vercel.app) - JWT token validation with Redis session storage - RBAC with department isolation - User, Role, Department models with pjctrl_ prefix - Alembic migrations with project-specific version table - Complete test coverage (13 tests) - Frontend (React + Vite): - AuthContext for state management - Login page with error handling - Protected route component - Dashboard with user info display - OpenSpec: - 7 capability specs defined - add-user-auth change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
3
backend/app/api/auth/__init__.py
Normal file
3
backend/app/api/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.auth import router
|
||||
|
||||
__all__ = ["router"]
|
||||
127
backend/app/api/auth/router.py
Normal file
127
backend/app/api/auth/router.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import create_access_token, create_token_payload
|
||||
from app.core.redis import get_redis
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import LoginRequest, LoginResponse, UserInfo
|
||||
from app.services.auth_client import (
|
||||
verify_credentials,
|
||||
AuthAPIError,
|
||||
AuthAPIConnectionError,
|
||||
)
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
request: LoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
redis_client=Depends(get_redis),
|
||||
):
|
||||
"""
|
||||
Authenticate user via external API and return JWT token.
|
||||
"""
|
||||
try:
|
||||
# Verify credentials with external API
|
||||
auth_result = await verify_credentials(request.email, request.password)
|
||||
except AuthAPIConnectionError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Authentication service temporarily unavailable",
|
||||
)
|
||||
except AuthAPIError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# Find or create user in local database
|
||||
user = db.query(User).filter(User.email == request.email).first()
|
||||
|
||||
if not user:
|
||||
# Create new user based on auth API response
|
||||
user = User(
|
||||
email=request.email,
|
||||
name=auth_result.get("name", request.email.split("@")[0]),
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
|
||||
# Get role name
|
||||
role_name = user.role.name if user.role else None
|
||||
|
||||
# Create token payload
|
||||
token_data = create_token_payload(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
role=role_name,
|
||||
department_id=user.department_id,
|
||||
is_system_admin=user.is_system_admin,
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(token_data)
|
||||
|
||||
# Store session in Redis
|
||||
redis_client.setex(
|
||||
f"session:{user.id}",
|
||||
900, # 15 minutes
|
||||
access_token,
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
user=UserInfo(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=role_name,
|
||||
department_id=user.department_id,
|
||||
is_system_admin=user.is_system_admin,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
redis_client=Depends(get_redis),
|
||||
):
|
||||
"""
|
||||
Logout user and invalidate session.
|
||||
"""
|
||||
# Remove session from Redis
|
||||
redis_client.delete(f"session:{current_user.id}")
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserInfo)
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get current authenticated user information.
|
||||
"""
|
||||
role_name = current_user.role.name if current_user.role else None
|
||||
|
||||
return UserInfo(
|
||||
id=current_user.id,
|
||||
email=current_user.email,
|
||||
name=current_user.name,
|
||||
role=role_name,
|
||||
department_id=current_user.department_id,
|
||||
is_system_admin=current_user.is_system_admin,
|
||||
)
|
||||
3
backend/app/api/departments/__init__.py
Normal file
3
backend/app/api/departments/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.departments import router
|
||||
|
||||
__all__ = ["router"]
|
||||
152
backend/app/api/departments/router.py
Normal file
152
backend/app/api/departments/router.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.department import Department
|
||||
from app.models.user import User
|
||||
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
|
||||
from app.middleware.auth import require_permission, require_system_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[DepartmentResponse])
|
||||
async def list_departments(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
"""
|
||||
List all departments.
|
||||
"""
|
||||
departments = db.query(Department).offset(skip).limit(limit).all()
|
||||
return departments
|
||||
|
||||
|
||||
@router.get("/{department_id}", response_model=DepartmentResponse)
|
||||
async def get_department(
|
||||
department_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
"""
|
||||
Get a specific department by ID.
|
||||
"""
|
||||
department = db.query(Department).filter(Department.id == department_id).first()
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Department not found",
|
||||
)
|
||||
return department
|
||||
|
||||
|
||||
@router.post("", response_model=DepartmentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_department(
|
||||
department_data: DepartmentCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_system_admin),
|
||||
):
|
||||
"""
|
||||
Create a new department. Requires system admin.
|
||||
"""
|
||||
# Check if parent exists if specified
|
||||
if department_data.parent_id:
|
||||
parent = db.query(Department).filter(
|
||||
Department.id == department_data.parent_id
|
||||
).first()
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent department not found",
|
||||
)
|
||||
|
||||
department = Department(**department_data.model_dump())
|
||||
db.add(department)
|
||||
db.commit()
|
||||
db.refresh(department)
|
||||
return department
|
||||
|
||||
|
||||
@router.patch("/{department_id}", response_model=DepartmentResponse)
|
||||
async def update_department(
|
||||
department_id: str,
|
||||
department_update: DepartmentUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_system_admin),
|
||||
):
|
||||
"""
|
||||
Update a department. Requires system admin.
|
||||
"""
|
||||
department = db.query(Department).filter(Department.id == department_id).first()
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Department not found",
|
||||
)
|
||||
|
||||
# Check if new parent exists if specified
|
||||
update_data = department_update.model_dump(exclude_unset=True)
|
||||
if "parent_id" in update_data and update_data["parent_id"]:
|
||||
# Prevent circular reference
|
||||
if update_data["parent_id"] == department_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Department cannot be its own parent",
|
||||
)
|
||||
|
||||
parent = db.query(Department).filter(
|
||||
Department.id == update_data["parent_id"]
|
||||
).first()
|
||||
if not parent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent department not found",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(department, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(department)
|
||||
return department
|
||||
|
||||
|
||||
@router.delete("/{department_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_department(
|
||||
department_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_system_admin),
|
||||
):
|
||||
"""
|
||||
Delete a department. Requires system admin.
|
||||
"""
|
||||
department = db.query(Department).filter(Department.id == department_id).first()
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Department not found",
|
||||
)
|
||||
|
||||
# Check if department has users
|
||||
user_count = db.query(User).filter(User.department_id == department_id).count()
|
||||
if user_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete department with {user_count} users",
|
||||
)
|
||||
|
||||
# Check if department has children
|
||||
child_count = db.query(Department).filter(
|
||||
Department.parent_id == department_id
|
||||
).count()
|
||||
if child_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete department with {child_count} child departments",
|
||||
)
|
||||
|
||||
db.delete(department)
|
||||
db.commit()
|
||||
3
backend/app/api/users/__init__.py
Normal file
3
backend/app/api/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.users import router
|
||||
|
||||
__all__ = ["router"]
|
||||
148
backend/app/api/users/router.py
Normal file
148
backend/app/api/users/router.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.schemas.user import UserResponse, UserUpdate
|
||||
from app.middleware.auth import (
|
||||
get_current_user,
|
||||
require_permission,
|
||||
require_system_admin,
|
||||
check_department_access,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
"""
|
||||
List all users. Filtered by department if not system admin.
|
||||
"""
|
||||
query = db.query(User)
|
||||
|
||||
# Filter by department if not system admin
|
||||
if not current_user.is_system_admin and current_user.department_id:
|
||||
query = query.filter(User.department_id == current_user.department_id)
|
||||
|
||||
users = query.offset(skip).limit(limit).all()
|
||||
return users
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.read")),
|
||||
):
|
||||
"""
|
||||
Get a specific user by ID.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Check department access
|
||||
if not check_department_access(current_user, user.department_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this user",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
user_update: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("users.write")),
|
||||
):
|
||||
"""
|
||||
Update user information.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Check department access
|
||||
if not check_department_access(current_user, user.department_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this user",
|
||||
)
|
||||
|
||||
# Prevent modification of system admin properties by non-system-admins
|
||||
if user.is_system_admin and not current_user.is_system_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify system administrator",
|
||||
)
|
||||
|
||||
# Update fields
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}/role", response_model=UserResponse)
|
||||
async def assign_role(
|
||||
user_id: str,
|
||||
role_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_system_admin),
|
||||
):
|
||||
"""
|
||||
Assign a role to a user. Requires system admin.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Prevent modification of system admin
|
||||
if user.is_system_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot modify system administrator role",
|
||||
)
|
||||
|
||||
# Verify role exists
|
||||
role = db.query(Role).filter(Role.id == role_id).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Role not found",
|
||||
)
|
||||
|
||||
# Prevent assigning system role to non-system-admin
|
||||
if role.is_system_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Cannot assign system role",
|
||||
)
|
||||
|
||||
user.role_id = role_id
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Database
|
||||
MYSQL_HOST: str = "localhost"
|
||||
MYSQL_PORT: int = 3306
|
||||
MYSQL_USER: str = "root"
|
||||
MYSQL_PASSWORD: str = ""
|
||||
MYSQL_DATABASE: str = "pjctrl"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_DB: int = 0
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_MINUTES: int = 15
|
||||
|
||||
# External Auth API
|
||||
AUTH_API_URL: str = "https://pj-auth-api.vercel.app"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
# System Admin
|
||||
SYSTEM_ADMIN_EMAIL: str = "ymirliu@panjit.com.tw"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
24
backend/app/core/database.py
Normal file
24
backend/app/core/database.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for getting database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
14
backend/app/core/redis.py
Normal file
14
backend/app/core/redis.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import redis
|
||||
from app.core.config import settings
|
||||
|
||||
redis_client = redis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
db=settings.REDIS_DB,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
def get_redis():
|
||||
"""Dependency for getting Redis client."""
|
||||
return redis_client
|
||||
82
backend/app/core/security.py
Normal file
82
backend/app/core/security.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Any
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Create a JWT access token.
|
||||
|
||||
Args:
|
||||
data: Data to encode in the token
|
||||
expires_delta: Optional custom expiration time
|
||||
|
||||
Returns:
|
||||
Encoded JWT token string
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire, "iat": datetime.utcnow()})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithm=settings.JWT_ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Decode and verify a JWT access token.
|
||||
|
||||
Args:
|
||||
token: The JWT token to decode
|
||||
|
||||
Returns:
|
||||
Decoded token payload if valid, None if invalid or expired
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def create_token_payload(
|
||||
user_id: str,
|
||||
email: str,
|
||||
role: str,
|
||||
department_id: Optional[str],
|
||||
is_system_admin: bool
|
||||
) -> dict:
|
||||
"""
|
||||
Create a standardized token payload.
|
||||
|
||||
Args:
|
||||
user_id: User's unique ID
|
||||
email: User's email
|
||||
role: User's role name
|
||||
department_id: User's department ID (can be None)
|
||||
is_system_admin: Whether user is a system admin
|
||||
|
||||
Returns:
|
||||
dict: Token payload
|
||||
"""
|
||||
return {
|
||||
"sub": user_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
"department_id": department_id,
|
||||
"is_system_admin": is_system_admin,
|
||||
}
|
||||
32
backend/app/main.py
Normal file
32
backend/app/main.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.departments import router as departments_router
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title="Project Control API",
|
||||
description="Cross-departmental project management system API",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["Authentication"])
|
||||
app.include_router(users_router.router, prefix="/api/users", tags=["Users"])
|
||||
app.include_router(departments_router.router, prefix="/api/departments", tags=["Departments"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
169
backend/app/middleware/auth.py
Normal file
169
backend/app/middleware/auth.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.core.redis import get_redis
|
||||
from app.models.user import User
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
redis_client=Depends(get_redis),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency to get the current authenticated user.
|
||||
|
||||
Validates the JWT token and checks session in Redis.
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# Decode and verify token
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Check session in Redis
|
||||
stored_token = redis_client.get(f"session:{user_id}")
|
||||
if stored_token is None or stored_token != token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired or invalid",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency to ensure user is active.
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_system_admin(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""
|
||||
Dependency to require system admin privileges.
|
||||
"""
|
||||
if not current_user.is_system_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="System admin privileges required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""
|
||||
Decorator factory to require specific permission.
|
||||
|
||||
Usage:
|
||||
@router.get("/protected")
|
||||
async def protected_route(user: User = Depends(require_permission("users.read"))):
|
||||
...
|
||||
"""
|
||||
def permission_checker(current_user: User = Depends(get_current_user)) -> User:
|
||||
# System admin has all permissions
|
||||
if current_user.is_system_admin:
|
||||
return current_user
|
||||
|
||||
# Check role permissions
|
||||
if current_user.role is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="No role assigned",
|
||||
)
|
||||
|
||||
permissions = current_user.role.permissions or {}
|
||||
|
||||
# Check for "all" permission
|
||||
if permissions.get("all"):
|
||||
return current_user
|
||||
|
||||
# Check specific permission
|
||||
if not permissions.get(permission):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permission '{permission}' required",
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
def check_department_access(
|
||||
user: User,
|
||||
resource_department_id: Optional[str],
|
||||
resource_security_level: str = "department",
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has access to a resource based on department isolation.
|
||||
|
||||
Args:
|
||||
user: The current user
|
||||
resource_department_id: Department ID of the resource
|
||||
resource_security_level: Security level ('public', 'department', 'confidential')
|
||||
|
||||
Returns:
|
||||
bool: True if user has access, False otherwise
|
||||
"""
|
||||
# System admin bypasses department isolation
|
||||
if user.is_system_admin:
|
||||
return True
|
||||
|
||||
# Public resources are accessible to all
|
||||
if resource_security_level == "public":
|
||||
return True
|
||||
|
||||
# No department specified on resource means accessible to all
|
||||
if resource_department_id is None:
|
||||
return True
|
||||
|
||||
# User must be in the same department
|
||||
if user.department_id == resource_department_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.department import Department
|
||||
|
||||
__all__ = ["User", "Role", "Department"]
|
||||
17
backend/app/models/department.py
Normal file
17
backend/app/models/department.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, ForeignKey, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
__tablename__ = "pjctrl_departments"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(100), nullable=False)
|
||||
parent_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
# Self-referential relationship
|
||||
parent = relationship("Department", remote_side=[id], backref="children")
|
||||
14
backend/app/models/role.py
Normal file
14
backend/app/models/role.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, JSON, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "pjctrl_roles"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(50), unique=True, nullable=False)
|
||||
permissions = Column(JSON, nullable=False, default=dict)
|
||||
is_system_role = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
25
backend/app/models/user.py
Normal file
25
backend/app/models/user.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from sqlalchemy import Column, String, ForeignKey, JSON, Boolean, DateTime, Numeric
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "pjctrl_users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email = Column(String(200), unique=True, nullable=False, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
department_id = Column(String(36), ForeignKey("pjctrl_departments.id"), nullable=True)
|
||||
role_id = Column(String(36), ForeignKey("pjctrl_roles.id"), nullable=True)
|
||||
skills = Column(JSON, nullable=True)
|
||||
capacity = Column(Numeric(5, 2), default=40.00)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_system_admin = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
department = relationship("Department", backref="users")
|
||||
role = relationship("Role", backref="users")
|
||||
18
backend/app/schemas/__init__.py
Normal file
18
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from app.schemas.auth import LoginRequest, LoginResponse, TokenPayload
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse, UserInDB
|
||||
from app.schemas.department import DepartmentCreate, DepartmentUpdate, DepartmentResponse
|
||||
from app.schemas.role import RoleResponse
|
||||
|
||||
__all__ = [
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"TokenPayload",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"UserInDB",
|
||||
"DepartmentCreate",
|
||||
"DepartmentUpdate",
|
||||
"DepartmentResponse",
|
||||
"RoleResponse",
|
||||
]
|
||||
36
backend/app/schemas/auth.py
Normal file
36
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: "UserInfo"
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: Optional[str] = None
|
||||
department_id: Optional[str] = None
|
||||
is_system_admin: bool = False
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: str
|
||||
email: str
|
||||
role: Optional[str] = None
|
||||
department_id: Optional[str] = None
|
||||
is_system_admin: bool = False
|
||||
exp: int
|
||||
iat: int
|
||||
|
||||
|
||||
# Update forward reference
|
||||
LoginResponse.model_rebuild()
|
||||
25
backend/app/schemas/department.py
Normal file
25
backend/app/schemas/department.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DepartmentBase(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class DepartmentCreate(DepartmentBase):
|
||||
pass
|
||||
|
||||
|
||||
class DepartmentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
class DepartmentResponse(DepartmentBase):
|
||||
id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
14
backend/app/schemas/role.py
Normal file
14
backend/app/schemas/role.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
permissions: Dict[str, Any]
|
||||
is_system_role: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
41
backend/app/schemas/user.py
Normal file
41
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: str
|
||||
name: str
|
||||
department_id: Optional[str] = None
|
||||
role_id: Optional[str] = None
|
||||
skills: Optional[List[str]] = None
|
||||
capacity: Optional[Decimal] = Decimal("40.00")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
department_id: Optional[str] = None
|
||||
role_id: Optional[str] = None
|
||||
skills: Optional[List[str]] = None
|
||||
capacity: Optional[Decimal] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: str
|
||||
is_active: bool
|
||||
is_system_admin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserInDB(UserResponse):
|
||||
pass
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
77
backend/app/services/auth_client.py
Normal file
77
backend/app/services/auth_client.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class AuthAPIError(Exception):
|
||||
"""Exception raised when external auth API returns an error."""
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class AuthAPIConnectionError(Exception):
|
||||
"""Exception raised when unable to connect to auth API."""
|
||||
pass
|
||||
|
||||
|
||||
async def verify_credentials(email: str, password: str) -> dict:
|
||||
"""
|
||||
Verify user credentials against the external auth API.
|
||||
|
||||
Args:
|
||||
email: User's email address
|
||||
password: User's password
|
||||
|
||||
Returns:
|
||||
dict: User info from auth API if successful
|
||||
|
||||
Raises:
|
||||
AuthAPIError: If credentials are invalid
|
||||
AuthAPIConnectionError: If unable to connect to auth API
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.AUTH_API_URL}/api/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 401:
|
||||
raise AuthAPIError("Invalid credentials", 401)
|
||||
else:
|
||||
raise AuthAPIError(
|
||||
f"Authentication failed: {response.text}",
|
||||
response.status_code
|
||||
)
|
||||
except httpx.ConnectError:
|
||||
raise AuthAPIConnectionError("Unable to connect to authentication service")
|
||||
except httpx.TimeoutException:
|
||||
raise AuthAPIConnectionError("Authentication service timeout")
|
||||
|
||||
|
||||
async def validate_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Validate a token with the external auth API.
|
||||
|
||||
Args:
|
||||
token: The auth token to validate
|
||||
|
||||
Returns:
|
||||
dict: Token payload if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{settings.AUTH_API_URL}/api/auth/validate",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except (httpx.ConnectError, httpx.TimeoutException):
|
||||
return None
|
||||
Reference in New Issue
Block a user