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, Depends, HTTPException, status
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field 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, verify_password
from app.core.security import create_access_token from app.core.deps import get_current_user, get_db_session
from app.schemas.user import Token from app.crud import user as user_crud
from app.schemas.user import Token, UserRead
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -17,16 +17,29 @@ router = APIRouter()
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login_for_access_token(payload: LoginRequest) -> Token: async def login_for_access_token(
if not (payload.username == "admin" and payload.password == "admin"): 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", 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( access_token = create_access_token(
subject="00000000-0000-0000-0000-000000000001", user_id=str(db_user.id),
role="PM", role=db_user.role,
expires_delta=timedelta(minutes=settings.JWT_EXPIRE_MINUTES), expires_minutes=None,
) )
return Token(access_token=access_token, token_type="bearer") 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 fastapi import APIRouter
from app.api.v1 import auth from app.api.v1 import auth, users
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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 fastapi import Depends, HTTPException, status
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession 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.db.session import SessionLocal
from app.schemas.user import TokenPayload 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: async with SessionLocal() as session:
yield session yield session
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> TokenPayload: async def get_current_user(
payload = decode_access_token(token) token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db_session)],
):
payload = decode_token(token)
try: try:
return TokenPayload(**payload) token_data = TokenPayload(**payload)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from exc ) 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 import HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings from app.core.config import settings
ALGORITHM = "HS256" ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer") 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: def create_access_token(*, user_id: str, role: str, expires_minutes: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)) expire = datetime.now(timezone.utc) + timedelta(
to_encode: Dict[str, Any] = {"sub": subject, "role": role, "exp": expire} 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) 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: try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[ALGORITHM])
except JWTError as exc: except JWTError as exc:
@@ -28,3 +32,11 @@ def decode_access_token(token: str) -> Dict[str, Any]:
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from exc ) from exc
return payload 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 typing import Sequence
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password
from app.models.user import User 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: 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() 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( user = User(
username=user_in.username, username=user_in.username,
hashed_password=hashed_password, hashed_password=hash_password(user_in.password),
role=user_in.role, role=user_in.role,
is_active=user_in.is_active, is_active=True,
) )
db.add(user) db.add(user)
await db.commit() await db.commit()
@@ -25,6 +32,41 @@ async def create(db: AsyncSession, user_in: UserCreate, hashed_password: str) ->
return user return user
async def list_users(db: AsyncSession, limit: int = 100) -> Sequence[User]: async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User:
result = await db.execute(select(User).limit(limit)) 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() 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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router from app.api.v1.router import api_router
from app.core.config import settings 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: def create_app() -> FastAPI:
@@ -10,6 +28,7 @@ def create_app() -> FastAPI:
title="CTMS API", title="CTMS API",
version="0.1.0", version="0.1.0",
debug=settings.ENV == "development", debug=settings.ENV == "development",
lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(
+16 -3
View File
@@ -4,24 +4,37 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
class UserBase(BaseModel): class UserBase(BaseModel):
username: str = Field(min_length=1) username: str = Field(min_length=1)
role: Literal["PM", "CRA", "PV", "IMP"] role: UserRole
is_active: bool = True is_active: bool = True
class UserCreate(UserBase): class UserCreate(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1) password: str = Field(min_length=1)
role: UserRole
class UserRead(UserBase): class UserRead(BaseModel):
id: uuid.UUID id: uuid.UUID
username: str
role: UserRole
is_active: bool
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) 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): class UserInDB(UserBase):
id: uuid.UUID id: uuid.UUID
hashed_password: str hashed_password: str
+3
View File
@@ -4,4 +4,7 @@ sqlalchemy==2.0.23
asyncpg==0.29.0 asyncpg==0.29.0
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-jose[cryptography]==3.3.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 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 1
/var/lib/postgresql/data /var/lib/postgresql/data
1765870839 1765872650
5432 5432
/var/run/postgresql /var/run/postgresql
* *