Step 2:用户 CRUD + bcrypt + JWT 真实鉴权 + RBAC 依赖
This commit is contained in:
+24
-11
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
1
|
||||
/var/lib/postgresql/data
|
||||
1765870839
|
||||
1765872650
|
||||
5432
|
||||
/var/run/postgresql
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user