Step 2:用户 CRUD + bcrypt + JWT 真实鉴权 + RBAC 依赖

This commit is contained in:
Cheng Zhou
2025-12-16 16:15:28 +08:00
parent 223d445a94
commit 65df698570
44 changed files with 215 additions and 33 deletions
+24 -11
View File
@@ -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
+2 -1
View File
@@ -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"])
+51
View File
@@ -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
+34 -6
View File
@@ -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
+16 -4
View File
@@ -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)
+49 -7
View File
@@ -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()
+19
View File
@@ -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(
+16 -3
View File
@@ -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
+3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
1
/var/lib/postgresql/data
1765870839
1765872650
5432
/var/run/postgresql
*