diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index aad10ee7..b0d8ccb7 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,11 +1,11 @@ -from datetime import timedelta - -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession -from app.core.config import settings -from app.core.security import create_access_token -from app.schemas.user import Token +from app.core.security import create_access_token, verify_password +from app.core.deps import get_current_user, get_db_session +from app.crud import user as user_crud +from app.schemas.user import Token, UserRead class LoginRequest(BaseModel): @@ -17,16 +17,29 @@ router = APIRouter() @router.post("/login", response_model=Token) -async def login_for_access_token(payload: LoginRequest) -> Token: - if not (payload.username == "admin" and payload.password == "admin"): +async def login_for_access_token( + payload: LoginRequest, db: AsyncSession = Depends(get_db_session) +) -> Token: + db_user = await user_crud.get_by_username(db, payload.username) + if not db_user or not verify_password(payload.password, db_user.hashed_password): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", ) + if not db_user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user", + ) access_token = create_access_token( - subject="00000000-0000-0000-0000-000000000001", - role="PM", - expires_delta=timedelta(minutes=settings.JWT_EXPIRE_MINUTES), + user_id=str(db_user.id), + role=db_user.role, + expires_minutes=None, ) return Token(access_token=access_token, token_type="bearer") + + +@router.get("/me", response_model=UserRead) +async def read_me(current_user=Depends(get_current_user)) -> UserRead: + return current_user diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 9697fad8..a30bc091 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.v1 import auth +from app.api.v1 import auth, users api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 00000000..e0b55678 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,51 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db_session, require_roles +from app.crud import user as user_crud +from app.schemas.user import UserCreate, UserRead, UserUpdate + +router = APIRouter() + + +@router.get("/", response_model=list[UserRead]) +async def list_users( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(require_roles(["ADMIN"])), +) -> list[UserRead]: + users = await user_crud.list_users(db, skip=skip, limit=limit) + return list(users) + + +@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user( + user_in: UserCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(require_roles(["ADMIN"])), +) -> UserRead: + existing = await user_crud.get_by_username(db, user_in.username) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists", + ) + user = await user_crud.create_user(db, user_in) + return user + + +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: uuid.UUID, + user_in: UserUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(require_roles(["ADMIN"])), +) -> UserRead: + db_user = await user_crud.get_by_id(db, user_id) + if not db_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + user = await user_crud.update_user(db, db_user, user_in) + return user diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 45cd682d..d2ceca90 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,26 +1,54 @@ -from typing import Annotated, AsyncGenerator +from typing import Annotated, AsyncGenerator, Callable, Iterable +import uuid from fastapi import Depends, HTTPException, status from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import decode_access_token, oauth2_scheme +from app.core.security import decode_token, oauth2_scheme +from app.crud import user as user_crud from app.db.session import SessionLocal from app.schemas.user import TokenPayload -async def get_db() -> AsyncGenerator[AsyncSession, None]: +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with SessionLocal() as session: yield session -async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> TokenPayload: - payload = decode_access_token(token) +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[AsyncSession, Depends(get_db_session)], +): + payload = decode_token(token) try: - return TokenPayload(**payload) + token_data = TokenPayload(**payload) except ValidationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) from exc + + user = await user_crud.get_by_id(db, uuid.UUID(str(token_data.sub))) + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive or missing user", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def require_roles(roles: Iterable[str]) -> Callable: + roles_set = set(roles) + + async def dependency(current_user=Depends(get_current_user)): + if current_user.role not in roles_set: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions", + ) + return current_user + + return dependency diff --git a/backend/app/core/security.py b/backend/app/core/security.py index e81a5017..fe9430ca 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -4,21 +4,25 @@ from typing import Any, Dict, Optional from fastapi import HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt +from passlib.context import CryptContext from app.core.config import settings ALGORITHM = "HS256" +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer") -def create_access_token(*, subject: str, role: str, expires_delta: Optional[timedelta] = None) -> str: - expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)) - to_encode: Dict[str, Any] = {"sub": subject, "role": role, "exp": expire} +def create_access_token(*, user_id: str, role: str, expires_minutes: Optional[int] = None) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES + ) + to_encode: Dict[str, Any] = {"sub": user_id, "role": role, "exp": expire} return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM) -def decode_access_token(token: str) -> Dict[str, Any]: +def decode_token(token: str) -> Dict[str, Any]: try: payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM]) except JWTError as exc: @@ -28,3 +32,11 @@ def decode_access_token(token: str) -> Dict[str, Any]: headers={"WWW-Authenticate": "Bearer"}, ) from exc return payload + + +def hash_password(plain_password: str) -> str: + return pwd_context.hash(plain_password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 28a36e3a..673851d3 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,10 +1,12 @@ +import uuid from typing import Sequence -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.core.security import hash_password from app.models.user import User -from app.schemas.user import UserCreate +from app.schemas.user import UserCreate, UserUpdate async def get_by_username(db: AsyncSession, username: str) -> User | None: @@ -12,12 +14,17 @@ async def get_by_username(db: AsyncSession, username: str) -> User | None: return result.scalar_one_or_none() -async def create(db: AsyncSession, user_in: UserCreate, hashed_password: str) -> User: +async def get_by_id(db: AsyncSession, user_id: uuid.UUID) -> User | None: + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def create_user(db: AsyncSession, user_in: UserCreate) -> User: user = User( username=user_in.username, - hashed_password=hashed_password, + hashed_password=hash_password(user_in.password), role=user_in.role, - is_active=user_in.is_active, + is_active=True, ) db.add(user) await db.commit() @@ -25,6 +32,41 @@ async def create(db: AsyncSession, user_in: UserCreate, hashed_password: str) -> return user -async def list_users(db: AsyncSession, limit: int = 100) -> Sequence[User]: - result = await db.execute(select(User).limit(limit)) +async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User: + update_data = {} + if user_in.role is not None: + update_data["role"] = user_in.role + if user_in.is_active is not None: + update_data["is_active"] = user_in.is_active + if user_in.password: + update_data["hashed_password"] = hash_password(user_in.password) + + if update_data: + await db.execute( + update(User) + .where(User.id == user.id) + .values(**update_data) + ) + await db.commit() + await db.refresh(user) + return user + + +async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Sequence[User]: + result = await db.execute(select(User).offset(skip).limit(limit)) return result.scalars().all() + + +async def ensure_admin_exists(db: AsyncSession, *, default_password: str = "admin123") -> None: + result = await db.execute(select(User).where(User.username == "admin")) + admin = result.scalar_one_or_none() + if admin: + return + new_admin = User( + username="admin", + hashed_password=hash_password(default_password), + role="ADMIN", + is_active=True, + ) + db.add(new_admin) + await db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index d9d20718..72f4d198 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,8 +1,26 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.v1.router import api_router from app.core.config import settings +from app.crud.user import ensure_admin_exists +from app.db.base import Base +from app.db.session import SessionLocal, engine + + +@asynccontextmanager +async def lifespan(_: FastAPI): + # Ensure models are imported so metadata is populated + from app.models import user as user_model # noqa: F401 + + if settings.ENV == "development": + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + async with SessionLocal() as session: + await ensure_admin_exists(session) + yield def create_app() -> FastAPI: @@ -10,6 +28,7 @@ def create_app() -> FastAPI: title="CTMS API", version="0.1.0", debug=settings.ENV == "development", + lifespan=lifespan, ) app.add_middleware( diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 251d3432..10609bc1 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -4,24 +4,37 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field +UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"] + class UserBase(BaseModel): username: str = Field(min_length=1) - role: Literal["PM", "CRA", "PV", "IMP"] + role: UserRole is_active: bool = True -class UserCreate(UserBase): +class UserCreate(BaseModel): + username: str = Field(min_length=1) password: str = Field(min_length=1) + role: UserRole -class UserRead(UserBase): +class UserRead(BaseModel): id: uuid.UUID + username: str + role: UserRole + is_active: bool created_at: datetime model_config = ConfigDict(from_attributes=True) +class UserUpdate(BaseModel): + role: Optional[UserRole] = None + is_active: Optional[bool] = None + password: Optional[str] = Field(default=None, min_length=1) + + class UserInDB(UserBase): id: uuid.UUID hashed_password: str diff --git a/backend/requirements.txt b/backend/requirements.txt index 2a9f27a3..396db03c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,7 @@ sqlalchemy==2.0.23 asyncpg==0.29.0 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.6 debugpy==1.8.0 diff --git a/pg_data/base/16384/1247 b/pg_data/base/16384/1247 index 8e65414d..37c318b1 100644 Binary files a/pg_data/base/16384/1247 and b/pg_data/base/16384/1247 differ diff --git a/pg_data/base/16384/1249 b/pg_data/base/16384/1249 index 9913faf1..2d790ada 100644 Binary files a/pg_data/base/16384/1249 and b/pg_data/base/16384/1249 differ diff --git a/pg_data/base/16384/1259 b/pg_data/base/16384/1259 index 2a5aedc8..804ed807 100644 Binary files a/pg_data/base/16384/1259 and b/pg_data/base/16384/1259 differ diff --git a/pg_data/base/16384/24576 b/pg_data/base/16384/24576 new file mode 100644 index 00000000..6d17cf9d Binary files /dev/null and b/pg_data/base/16384/24576 differ diff --git a/pg_data/base/16384/24581 b/pg_data/base/16384/24581 new file mode 100644 index 00000000..2bf92b71 Binary files /dev/null and b/pg_data/base/16384/24581 differ diff --git a/pg_data/base/16384/24583 b/pg_data/base/16384/24583 new file mode 100644 index 00000000..e95ae803 Binary files /dev/null and b/pg_data/base/16384/24583 differ diff --git a/pg_data/base/16384/2579 b/pg_data/base/16384/2579 index 2e8f4ab5..1f883b0e 100644 Binary files a/pg_data/base/16384/2579 and b/pg_data/base/16384/2579 differ diff --git a/pg_data/base/16384/2604 b/pg_data/base/16384/2604 index e69de29b..ee1089e8 100644 Binary files a/pg_data/base/16384/2604 and b/pg_data/base/16384/2604 differ diff --git a/pg_data/base/16384/2606 b/pg_data/base/16384/2606 index 7cbc7cfb..acd8e820 100644 Binary files a/pg_data/base/16384/2606 and b/pg_data/base/16384/2606 differ diff --git a/pg_data/base/16384/2608 b/pg_data/base/16384/2608 index 1c94af70..581c0e0c 100644 Binary files a/pg_data/base/16384/2608 and b/pg_data/base/16384/2608 differ diff --git a/pg_data/base/16384/2610 b/pg_data/base/16384/2610 index f4a7a94e..bafbd017 100644 Binary files a/pg_data/base/16384/2610 and b/pg_data/base/16384/2610 differ diff --git a/pg_data/base/16384/2656 b/pg_data/base/16384/2656 index e5244be8..6f6218b6 100644 Binary files a/pg_data/base/16384/2656 and b/pg_data/base/16384/2656 differ diff --git a/pg_data/base/16384/2657 b/pg_data/base/16384/2657 index 0f5e2d99..e042a0b1 100644 Binary files a/pg_data/base/16384/2657 and b/pg_data/base/16384/2657 differ diff --git a/pg_data/base/16384/2658 b/pg_data/base/16384/2658 index 9ca29fea..2e1ddb6e 100644 Binary files a/pg_data/base/16384/2658 and b/pg_data/base/16384/2658 differ diff --git a/pg_data/base/16384/2659 b/pg_data/base/16384/2659 index c4b6b751..a9637f87 100644 Binary files a/pg_data/base/16384/2659 and b/pg_data/base/16384/2659 differ diff --git a/pg_data/base/16384/2662 b/pg_data/base/16384/2662 index 2c81de59..3b68991b 100644 Binary files a/pg_data/base/16384/2662 and b/pg_data/base/16384/2662 differ diff --git a/pg_data/base/16384/2663 b/pg_data/base/16384/2663 index 222aab71..aa470382 100644 Binary files a/pg_data/base/16384/2663 and b/pg_data/base/16384/2663 differ diff --git a/pg_data/base/16384/2664 b/pg_data/base/16384/2664 index 59112306..ea4f7f6c 100644 Binary files a/pg_data/base/16384/2664 and b/pg_data/base/16384/2664 differ diff --git a/pg_data/base/16384/2665 b/pg_data/base/16384/2665 index 3fddb032..6c7039dd 100644 Binary files a/pg_data/base/16384/2665 and b/pg_data/base/16384/2665 differ diff --git a/pg_data/base/16384/2666 b/pg_data/base/16384/2666 index cc367ff4..c8863b97 100644 Binary files a/pg_data/base/16384/2666 and b/pg_data/base/16384/2666 differ diff --git a/pg_data/base/16384/2667 b/pg_data/base/16384/2667 index a75ece7f..5ff452d1 100644 Binary files a/pg_data/base/16384/2667 and b/pg_data/base/16384/2667 differ diff --git a/pg_data/base/16384/2673 b/pg_data/base/16384/2673 index d92e97d3..bd48d8b5 100644 Binary files a/pg_data/base/16384/2673 and b/pg_data/base/16384/2673 differ diff --git a/pg_data/base/16384/2674 b/pg_data/base/16384/2674 index ce34de15..37648bc4 100644 Binary files a/pg_data/base/16384/2674 and b/pg_data/base/16384/2674 differ diff --git a/pg_data/base/16384/2678 b/pg_data/base/16384/2678 index b0064249..b884e88d 100644 Binary files a/pg_data/base/16384/2678 and b/pg_data/base/16384/2678 differ diff --git a/pg_data/base/16384/2679 b/pg_data/base/16384/2679 index 249a9ceb..982b9bc7 100644 Binary files a/pg_data/base/16384/2679 and b/pg_data/base/16384/2679 differ diff --git a/pg_data/base/16384/2703 b/pg_data/base/16384/2703 index 9139a220..fd58ac03 100644 Binary files a/pg_data/base/16384/2703 and b/pg_data/base/16384/2703 differ diff --git a/pg_data/base/16384/2704 b/pg_data/base/16384/2704 index 09700963..2516a3a6 100644 Binary files a/pg_data/base/16384/2704 and b/pg_data/base/16384/2704 differ diff --git a/pg_data/base/16384/3455 b/pg_data/base/16384/3455 index 954daff9..d8b4e01a 100644 Binary files a/pg_data/base/16384/3455 and b/pg_data/base/16384/3455 differ diff --git a/pg_data/base/16384/pg_internal.init b/pg_data/base/16384/pg_internal.init index 225582cf..41961490 100644 Binary files a/pg_data/base/16384/pg_internal.init and b/pg_data/base/16384/pg_internal.init differ diff --git a/pg_data/global/1262 b/pg_data/global/1262 index 091e5a14..c67ed0fb 100644 Binary files a/pg_data/global/1262 and b/pg_data/global/1262 differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index db460be1..037eaa80 100644 Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ diff --git a/pg_data/global/pg_internal.init b/pg_data/global/pg_internal.init index aab41092..3bb1fa45 100644 Binary files a/pg_data/global/pg_internal.init and b/pg_data/global/pg_internal.init differ diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001 index e2615a5f..674239a5 100644 Binary files a/pg_data/pg_wal/000000010000000000000001 and b/pg_data/pg_wal/000000010000000000000001 differ diff --git a/pg_data/pg_xact/0000 b/pg_data/pg_xact/0000 index f638fa5e..b4898632 100644 Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ diff --git a/pg_data/postmaster.pid b/pg_data/postmaster.pid index ce01b61d..e6ecfbd7 100644 --- a/pg_data/postmaster.pid +++ b/pg_data/postmaster.pid @@ -1,6 +1,6 @@ 1 /var/lib/postgresql/data -1765870839 +1765872650 5432 /var/run/postgresql *